Compare commits
31
Commits
0f263c3a19
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70770236eb | ||
|
|
2179744ddf | ||
|
|
da7132d4ce | ||
|
|
c86803bbd6 | ||
|
|
528af07890 | ||
|
|
a2dcc4df65 | ||
|
|
86334c2752 | ||
|
|
3346b51b21 | ||
|
|
d1111983b0 | ||
|
|
27292470f6 | ||
|
|
47dff5a1ca | ||
|
|
93d6bd9127 | ||
|
|
213dd10dbd | ||
|
|
a31a235422 | ||
|
|
d320a4fd7a | ||
|
|
a0d8c62082 | ||
|
|
43d0fc5578 | ||
|
|
d7de0d92cf | ||
|
|
25bdb6f34b | ||
|
|
f4b57abd93 | ||
|
|
2fd606a3aa | ||
|
|
9f9e4c7cd0 | ||
|
|
7df9c1fb4e | ||
|
|
9b309bb942 | ||
|
|
7a52099a59 | ||
|
|
b244451c42 | ||
|
|
1d53b66e3f | ||
|
|
6cd7fcc079 | ||
|
|
3f6866e075 | ||
|
|
d24ee9bf91 | ||
|
|
a93e067e04 |
@@ -1,4 +1,5 @@
|
||||
# Nur *.php im Webroot und assets/ sollen direkt aufrufbar sein. Interne
|
||||
# Nur *.php im Webroot, assets/ und versionierte Rechtstexte sollen direkt
|
||||
# aufrufbar sein. Interne
|
||||
# Verzeichnisse (Sessions, Mail-Logs, Migrationen, CLI-Skripte, Doku,
|
||||
# App-Bausteine, Legacy-DB-Shim, Vendor-Bibliotheken), die Git-Metadaten und
|
||||
# Konfigurationsdateien mit Zugangsdaten duerfen nicht direkt per URL
|
||||
@@ -19,6 +20,12 @@
|
||||
RewriteCond %{REQUEST_URI} ^/(var|database|scripts|docs|app|lib)/ [NC]
|
||||
RewriteRule ^ - [F]
|
||||
|
||||
# Rechtstexte sind als speicherbare Textfassung absichtlich öffentlich;
|
||||
# alle anderen/versehentlich abgelegten Dateien im Ordner bleiben gesperrt.
|
||||
RewriteCond %{REQUEST_URI} ^/legal/ [NC]
|
||||
RewriteCond %{REQUEST_URI} !^/legal/(agb|datenschutz|avv|widerruf)-[0-9]{4}-[0-9]{2}-[0-9]{2}\.txt$ [NC]
|
||||
RewriteRule ^ - [F]
|
||||
|
||||
# Vendor-Verzeichnisse werden ausschliesslich serverseitig eingebunden
|
||||
# (TCPDF ueber exportKaffeeliste.php); direkte URL-Aufrufe ihrer PHP-
|
||||
# Dateien sind nie erwuenscht.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
if (!saas_can_manage_tenant_settings($user)) {
|
||||
http_response_code(403);
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: mandant-einstellungen.php');
|
||||
exit;
|
||||
}
|
||||
app_require_csrf();
|
||||
|
||||
$planCode = (string)($_POST['plan_code'] ?? '');
|
||||
$plans = billing_plans();
|
||||
if (!isset($plans[$planCode]) || $plans[$planCode]['stripe_lookup_key'] === null) {
|
||||
http_response_code(400);
|
||||
exit('Ungültiger kostenpflichtiger Tarif.');
|
||||
}
|
||||
if (!billing_plan_selectable_for($pdo, (int)$user['tenant_id'], $planCode)) {
|
||||
http_response_code(400);
|
||||
exit('Dieser Tarif deckt eure aktuelle Teilnehmerzahl nicht ab.');
|
||||
}
|
||||
if (!in_array((string)($user['customer_type'] ?? ''), ['business', 'consumer'], true)) {
|
||||
http_response_code(400);
|
||||
exit('Bitte wähle zuerst in den Mandant-Einstellungen, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.');
|
||||
}
|
||||
if (!empty($user['contract_ends_at'])) {
|
||||
http_response_code(409);
|
||||
exit('Für diesen Vertrag liegt bereits eine Kündigung vor. Bitte nutze das Ticketsystem, wenn du sie zurücknehmen möchtest.');
|
||||
}
|
||||
|
||||
$plan = $plans[$planCode];
|
||||
$current = billing_fetch_or_init($pdo, (int)$user['tenant_id']);
|
||||
$isSwitch = !empty($current['stripe_subscription_id']);
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>Tarif kostenpflichtig bestellen – Kaffeeliste</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="assets/css/main.css">
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
<section class="public-section public-legal">
|
||||
<nav class="public-nav" aria-label="Hauptnavigation"><strong><a href="landing.php">Kaffeeliste</a></strong></nav>
|
||||
<header class="major"><h1>Bestellung prüfen</h1></header>
|
||||
|
||||
<div class="public-panel order-summary">
|
||||
<h2><?php echo saas_html($plan['label']); ?></h2>
|
||||
<p class="public-price"><?php echo saas_html(saas_format_money_cents($plan['price_cents'])); ?> € <span>pro Monat</span></p>
|
||||
<ul>
|
||||
<li>Webbasierte Kaffeeliste für bis zu <?php echo (int)$plan['max_participants']; ?> aktive Teilnehmer</li>
|
||||
<li>Monatliche Abrechnung im Voraus über Stripe; keine Versand- oder Zusatzkosten</li>
|
||||
<li>Unbefristeter Vertrag, keine Mindestlaufzeit über die laufende Monatsperiode hinaus</li>
|
||||
<li>Jederzeit kündbar zum Ende der laufenden Abrechnungsperiode</li>
|
||||
<li>Erforderlich: aktueller Browser, Internetzugang und erreichbare E-Mail-Adresse</li>
|
||||
<li>Gesetzliche Mängelrechte und Aktualisierungsansprüche bleiben bestehen</li>
|
||||
</ul>
|
||||
<?php if ($isSwitch): ?>
|
||||
<p><b>Tarifwechsel:</b> Die neue Teilnehmergrenze gilt sofort. Für die laufende Periode gibt es keine zusätzliche Belastung oder Gutschrift; ab der nächsten Verlängerung werden <?php echo saas_html(saas_format_money_cents($plan['price_cents'])); ?> € monatlich berechnet.</p>
|
||||
<?php else: ?>
|
||||
<p><b>Neubuchung:</b> Nach dieser Bestätigung wirst du zur sicheren Stripe-Zahlungsseite weitergeleitet. Dort wählst du das Zahlungsmittel und schließt die Bestellung abschließend ab.</p>
|
||||
<?php endif; ?>
|
||||
<p>Anbieter: Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover. Kontakt siehe <a href="impressum.php" target="_blank">Impressum</a>.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="abo-upgrade.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="plan_code" value="<?php echo saas_html($planCode); ?>">
|
||||
<div>
|
||||
<input type="checkbox" name="accept_terms" id="accept_terms" value="1" required>
|
||||
<label for="accept_terms">Ich akzeptiere die <a href="agb.php" target="_blank" rel="noopener">AGB</a>.</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" name="acknowledge_withdrawal" id="acknowledge_withdrawal" value="1" required>
|
||||
<label for="acknowledge_withdrawal">Ich habe die <a href="widerruf.php" target="_blank" rel="noopener">Widerrufsbelehrung samt Musterformular</a> erhalten und gelesen.</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" name="request_early_performance" id="request_early_performance" value="1" required>
|
||||
<label for="request_early_performance">Ich verlange ausdrücklich, dass die kostenpflichtige Leistung vor Ablauf der 14-tägigen Widerrufsfrist beginnt. Mir ist bekannt, dass ich bei Widerruf Wertersatz für die bis dahin erbrachte Leistung schulden kann.</label>
|
||||
</div>
|
||||
<p><small>Gesamtpreis: <?php echo saas_html(saas_format_money_cents($plan['price_cents'])); ?> € je monatlicher Abrechnungsperiode. Es findet keine personalisierte Preisbildung statt.</small></p>
|
||||
<ul class="actions">
|
||||
<li><button type="submit" class="primary">zahlungspflichtig bestellen</button></li>
|
||||
<li><a href="mandant-einstellungen.php" class="button">Abbrechen</a></li>
|
||||
</ul>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
<script src="assets/js/util.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+49
-6
@@ -4,6 +4,7 @@ require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
require_once __DIR__ . '/app/saas-mail.php';
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
@@ -34,12 +35,22 @@ if (!billing_plan_selectable_for($pdo, $tenantId, $planCode)) {
|
||||
exit('Dieser Tarif deckt eure aktuelle Teilnehmerzahl nicht ab. Bitte einen passenden Tarif wählen.');
|
||||
}
|
||||
|
||||
if ($planCode !== 'free' && !in_array((string)($user['customer_type'] ?? ''), ['business', 'consumer'], true)) {
|
||||
http_response_code(400);
|
||||
exit('Bitte wähle zuerst in den Mandant-Einstellungen, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.');
|
||||
}
|
||||
|
||||
$billing = billing_fetch_or_init($pdo, $tenantId);
|
||||
$baseUrl = saas_app_url('mandant-einstellungen.php');
|
||||
|
||||
if ($planCode !== 'free' && !empty($user['contract_ends_at'])) {
|
||||
http_response_code(409);
|
||||
exit('Für diesen Vertrag liegt bereits eine Kündigung vor. Bitte nutze das Ticketsystem, wenn du sie zurücknehmen möchtest.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Fall 1: Wechsel auf "free" (kein Stripe-Preis) mit bestehender bezahlter
|
||||
// Subscription -> echtes Kuendigen der Subscription, kein Checkout noetig.
|
||||
// Wechsel auf "free": die bezahlte Subscription wird zum Ende der bereits
|
||||
// bezahlten Periode gekündigt. Bis dahin bleiben Tarif und Funktionen aktiv.
|
||||
if ($plans[$planCode]['stripe_lookup_key'] === null) {
|
||||
if ($planCode !== 'free') {
|
||||
// enterprise hat ebenfalls keinen Stripe-Preis, ist aber kein
|
||||
@@ -49,7 +60,7 @@ try {
|
||||
}
|
||||
|
||||
if ($billing['stripe_subscription_id'] !== null) {
|
||||
$cancelResult = stripe_cancel_subscription((string)$billing['stripe_subscription_id']);
|
||||
$cancelResult = stripe_schedule_subscription_cancellation((string)$billing['stripe_subscription_id']);
|
||||
if (!$cancelResult['ok']) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.downgrade_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $cancelResult['error']]);
|
||||
http_response_code(502);
|
||||
@@ -57,11 +68,20 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
if ($billing['stripe_subscription_id'] === null) {
|
||||
billing_update($pdo, $tenantId, [
|
||||
'plan_code' => 'free',
|
||||
'subscription_status' => 'canceled',
|
||||
'subscription_status' => 'active',
|
||||
]);
|
||||
} else {
|
||||
billing_update($pdo, $tenantId, [
|
||||
'subscription_status' => 'canceling',
|
||||
'current_period_end' => $cancelResult['current_period_end'],
|
||||
]);
|
||||
}
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.cancellation_scheduled', 'tenant', $tenantId, [
|
||||
'current_period_end' => $cancelResult['current_period_end'] ?? null,
|
||||
]);
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.downgraded_to_free', 'tenant', $tenantId);
|
||||
|
||||
header('Location: ' . $baseUrl . '?upgrade=success');
|
||||
exit;
|
||||
@@ -73,10 +93,24 @@ try {
|
||||
exit('Der Tarif ist bei Stripe aktuell nicht verfügbar. Bitte später erneut versuchen.');
|
||||
}
|
||||
|
||||
if (empty($_POST['accept_terms']) || empty($_POST['acknowledge_withdrawal']) || empty($_POST['request_early_performance'])) {
|
||||
http_response_code(400);
|
||||
exit('Bitte bestätige AGB, Widerrufsbelehrung und den gewünschten sofortigen Leistungsbeginn auf der Bestellseite.');
|
||||
}
|
||||
|
||||
$orderMetadata = [
|
||||
'plan_code' => $planCode,
|
||||
'monthly_price_cents' => (int)$plans[$planCode]['price_cents'],
|
||||
'customer_type' => (string)($user['customer_type'] ?? 'business'),
|
||||
];
|
||||
app_record_legal_acceptance($pdo, $tenantId, (int)$user['user_id'], 'terms', 'paid_order', $orderMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, (int)$user['user_id'], 'withdrawal_information', 'paid_order', $orderMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, (int)$user['user_id'], 'early_performance', 'paid_order', $orderMetadata);
|
||||
|
||||
// Fall 2: Es gibt bereits eine aktive/bezahlte Subscription bei Stripe ->
|
||||
// Preis in-place wechseln (Up- oder Downgrade zwischen bezahlten Stufen),
|
||||
// statt eine weitere Checkout-Session zu erzeugen. Stripe rechnet die
|
||||
// Differenz automatisch anteilig ab (Proration).
|
||||
// neue Preis gilt ohne Zwischenbelastung ab der nächsten Verlängerung.
|
||||
if ($billing['stripe_subscription_id'] !== null) {
|
||||
$subscription = stripe_get_subscription((string)$billing['stripe_subscription_id']);
|
||||
|
||||
@@ -102,6 +136,15 @@ try {
|
||||
]);
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.plan_switched', 'tenant', $tenantId, ['plan_code' => $planCode]);
|
||||
|
||||
$confirmation = saas_send_paid_contract_confirmation($pdo, $tenantId, $planCode);
|
||||
if (empty($confirmation['ok'])) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.contract_confirmation_failed', 'tenant', $tenantId, [
|
||||
'error' => $confirmation['error'] ?? 'unknown',
|
||||
]);
|
||||
header('Location: ' . $baseUrl . '?upgrade=success&contract_mail_failed=1');
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Location: ' . $baseUrl . '?upgrade=success');
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -1,127 +1,33 @@
|
||||
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
|
||||
<?php
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
app_record_public_page_view('terms');
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>AGB – Kaffeeliste</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="assets/css/main.css">
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
<section class="public-section public-legal">
|
||||
<nav class="public-nav" aria-label="Hauptnavigation">
|
||||
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
||||
<ul class="actions">
|
||||
<li><a href="login.php" class="button">Login</a></li>
|
||||
</ul>
|
||||
<ul class="actions"><li><a href="login.php" class="button">Login</a></li></ul>
|
||||
</nav>
|
||||
|
||||
<header class="major">
|
||||
<h1>Allgemeine Geschäftsbedingungen</h1>
|
||||
</header>
|
||||
|
||||
<div class="hint-box error">
|
||||
<p><b>Hinweis: Dies ist ein Entwurf</b>, automatisiert aus der
|
||||
Produktbeschreibung abgeleitet. Er ersetzt keine anwaltliche Prüfung und
|
||||
sollte vor Produktivbetrieb von einem Rechtsanwalt bzw. einer
|
||||
Rechtsanwältin für IT-/Vertragsrecht geprüft und an den tatsächlichen
|
||||
Geschäftsbetrieb angepasst werden – insbesondere Haftungsbegrenzung,
|
||||
Kündigungsfristen und die konkrete Preisliste.</p>
|
||||
</div>
|
||||
|
||||
<h2>§ 1 Geltungsbereich</h2>
|
||||
<p>Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle
|
||||
Verträge zwischen Clemens Creutzburg (nachfolgend „Anbieter") und
|
||||
Unternehmen, die den Dienst „Kaffeeliste" (nachfolgend „Dienst") als
|
||||
Kunde nutzen. Der Dienst richtet sich ausschließlich an Unternehmer im
|
||||
Sinne von § 14 BGB, nicht an Verbraucher.</p>
|
||||
|
||||
<h2>§ 2 Vertragsschluss</h2>
|
||||
<p>Der Vertrag kommt durch die Registrierung eines Kundenkontos und die
|
||||
Bestätigung dieser AGB zustande. Der Anbieter behält sich vor, eine
|
||||
Registrierung ohne Angabe von Gründen abzulehnen.</p>
|
||||
|
||||
<h2>§ 3 Leistungsbeschreibung</h2>
|
||||
<p>Der Anbieter stellt eine webbasierte Anwendung zur Verwaltung einer
|
||||
internen Kaffee- oder Getränkekasse bereit, insbesondere zur Erfassung von
|
||||
Verbrauch, Einzahlungen, Salden sowie zur Verwaltung von Mitgliedern und
|
||||
Zugängen des Kunden. Der konkrete Funktionsumfang ergibt sich aus der
|
||||
jeweils aktuellen Produktbeschreibung. Der Anbieter ist berechtigt, den
|
||||
Dienst weiterzuentwickeln, sofern der vertraglich vereinbarte
|
||||
Funktionsumfang im Kern erhalten bleibt.</p>
|
||||
|
||||
<h2>§ 4 Preise und Zahlungsbedingungen</h2>
|
||||
<p>Es gelten die jeweils aktuellen, unter <a href="preise.php">Preise</a>
|
||||
veröffentlichten Preise, gestaffelt nach der Anzahl aktiver Teilnehmer des
|
||||
Kunden. Der Anbieter ist gemäß § 19 UStG von der Umsatzsteuer befreit; die
|
||||
genannten Preise sind Endpreise ohne gesonderten Ausweis von
|
||||
Umsatzsteuer. Die Abrechnung erfolgt monatlich im Voraus. Bei
|
||||
Überschreiten einer Teilnehmerstufe während der Laufzeit wird automatisch
|
||||
zur nächsthöheren Preisstufe gewechselt. Preisänderungen werden dem Kunden
|
||||
vor Wirksamwerden in Textform angekündigt.</p>
|
||||
|
||||
<h2>§ 5 Pflichten des Kunden</h2>
|
||||
<p>Der Kunde ist für die Rechtmäßigkeit der Verarbeitung
|
||||
personenbezogener Daten seiner Mitglieder im Dienst verantwortlich
|
||||
(Verantwortlicher im Sinne der DSGVO). Der Kunde ist verpflichtet, seine
|
||||
Zugangsdaten vertraulich zu behandeln und den Anbieter unverzüglich über
|
||||
einen Verdacht auf unbefugten Zugriff zu informieren.</p>
|
||||
|
||||
<h2>§ 6 Verfügbarkeit</h2>
|
||||
<p>Der Anbieter ist bemüht, den Dienst mit einer angemessenen
|
||||
Verfügbarkeit im Jahresmittel bereitzustellen. Wartungsfenster werden nach
|
||||
Möglichkeit außerhalb der üblichen Geschäftszeiten durchgeführt. Ein
|
||||
bestimmter Verfügbarkeitsgrad wird ohne gesonderte SLA-Vereinbarung nicht
|
||||
geschuldet.</p>
|
||||
|
||||
<h2>§ 7 Datenschutz und Auftragsverarbeitung</h2>
|
||||
<p>Der Anbieter verarbeitet personenbezogene Daten der Mitglieder des
|
||||
Kunden ausschließlich im Auftrag und nach Weisung des Kunden. Die Details
|
||||
regelt ein gesonderter Auftragsverarbeitungsvertrag (AVV), den der Kunde
|
||||
vor Verarbeitung personenbezogener Daten mit dem Anbieter abschließt.
|
||||
Einzelheiten zur Verarbeitung sind der Datenschutzerklärung zu entnehmen.</p>
|
||||
|
||||
<h2>§ 8 Laufzeit und Kündigung</h2>
|
||||
<p>Der Vertrag läuft auf unbestimmte Zeit und ist von beiden Seiten mit
|
||||
einer Frist von einem Monat zum Monatsende kündbar, sofern nichts anderes
|
||||
vereinbart ist. Das Recht zur außerordentlichen Kündigung aus wichtigem
|
||||
Grund bleibt unberührt. Nach Vertragsende kann der Kunde seine Daten über
|
||||
die Exportfunktion herunterladen; die Daten werden nach angemessener Frist
|
||||
gelöscht.</p>
|
||||
|
||||
<h2>§ 9 Haftung</h2>
|
||||
<p>Der Anbieter haftet unbeschränkt für Vorsatz und grobe Fahrlässigkeit
|
||||
sowie nach Maßgabe des Produkthaftungsgesetzes. Für leichte Fahrlässigkeit
|
||||
haftet der Anbieter nur bei Verletzung einer wesentlichen Vertragspflicht
|
||||
(Kardinalpflicht) und begrenzt auf den vertragstypisch vorhersehbaren
|
||||
Schaden. Im Übrigen ist die Haftung ausgeschlossen.</p>
|
||||
|
||||
<h2>§ 10 Änderungen dieser AGB</h2>
|
||||
<p>Der Anbieter kann diese AGB mit Wirkung für die Zukunft ändern, sofern
|
||||
dies zur Anpassung an geänderte rechtliche oder technische
|
||||
Rahmenbedingungen erforderlich ist. Der Kunde wird über Änderungen
|
||||
rechtzeitig informiert und kann widersprechen; im Falle des Widerspruchs
|
||||
gilt § 8.</p>
|
||||
|
||||
<h2>§ 11 Schlussbestimmungen</h2>
|
||||
<p>Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des
|
||||
UN-Kaufrechts. Gerichtsstand ist, soweit gesetzlich zulässig, der Sitz des
|
||||
Anbieters. Sollte eine Bestimmung dieser AGB unwirksam sein, bleibt die
|
||||
Wirksamkeit der übrigen Bestimmungen unberührt.</p>
|
||||
|
||||
<header class="major"><h1>Allgemeine Geschäftsbedingungen</h1></header>
|
||||
<?php echo app_render_legal_document('terms'); ?>
|
||||
<p><a href="legal/agb-<?php echo htmlspecialchars(APP_TERMS_VERSION, ENT_QUOTES, 'UTF-8'); ?>.txt" download>AGB als Textdatei speichern</a></p>
|
||||
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="public-footer-links">
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
<a href="preise.php">Preise</a>
|
||||
</footer>
|
||||
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
+81
-133
@@ -2,6 +2,7 @@
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -16,6 +17,13 @@ $istAdmin = $saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUs
|
||||
// Einstellungen fuer die Hinweise zu den aktivierten Zahlungswegen.
|
||||
$settings = $saasUser !== null ? saas_fetch_tenant_settings($pdo, (int)$saasUser['tenant_id']) : null;
|
||||
|
||||
// Gleiche Logik wie im Menue: was der Betreiber gesperrt oder der Mandant
|
||||
// selbst abgeschaltet hat, wird hier auch nicht erklaert.
|
||||
$anleitungTenantId = $saasUser !== null ? (int)$saasUser['tenant_id'] : 0;
|
||||
$kann = static function (string $feature) use ($pdo, $anleitungTenantId): bool {
|
||||
return $anleitungTenantId <= 0 || app_feature_available($pdo, $anleitungTenantId, $feature);
|
||||
};
|
||||
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
include "nav.php";
|
||||
@@ -25,8 +33,8 @@ include "nav.php";
|
||||
<div class="content">
|
||||
|
||||
<h2>Anleitung</h2>
|
||||
<p>Diese Seite erklärt die wichtigsten Funktionen der Kaffeeliste. Oben stehen
|
||||
die Punkte, die jedes Mitglied betreffen, darunter die Verwaltungsfunktionen.</p>
|
||||
<p>Kurzüberblick über die Funktionen. Oben steht, was jedes Mitglied betrifft,
|
||||
darunter die Verwaltung.</p>
|
||||
|
||||
<?php if ($saasUser === null): ?>
|
||||
<div class="hint-box">
|
||||
@@ -40,34 +48,20 @@ include "nav.php";
|
||||
<h2>Für alle Mitglieder</h2>
|
||||
|
||||
<h3>Meine Kaffeeliste</h3>
|
||||
<p>Das ist deine Startseite. Sie zeigt deinen <b>aktuellen Stand</b>, deine
|
||||
Jahresübersicht sowie die letzten Einzahlungen und Striche.</p>
|
||||
<ul>
|
||||
<li><b>Guthaben</b> (positiver Betrag): Du hast mehr eingezahlt, als du verbraucht hast.</li>
|
||||
<li><b>Schulden</b> (negativer Betrag): Es ist Zeit für eine Einzahlung.</li>
|
||||
</ul>
|
||||
<p>In der <b>Monatsübersicht</b> siehst du, wie viele Striche in welchem Monat
|
||||
zusammengekommen sind.</p>
|
||||
<p>Deine Startseite zeigt den aktuellen Stand, die Jahres- und Monatsübersicht sowie
|
||||
die letzten Buchungen. Positiver Betrag = Guthaben, negativer Betrag = offener Betrag.</p>
|
||||
|
||||
<?php if ($kann('self_entry')): ?>
|
||||
<h3>Striche selbst eintragen</h3>
|
||||
<p>Wenn die Verwaltung das freigeschaltet hat, kannst du deinen Kaffee direkt auf
|
||||
der Startseite eintragen – ein oder zwei Striche pro Vorgang.</p>
|
||||
<div class="hint-box">
|
||||
<p><b>Vertippt?</b> Direkt darunter kannst du deinen <b>zuletzt selbst
|
||||
eingetragenen</b> Strich wieder stornieren. Striche, die der Kassenwart über die
|
||||
Sammelerfassung eingetragen hat, kannst du nicht selbst stornieren – wende dich
|
||||
dafür an die Verwaltung.</p>
|
||||
</div>
|
||||
<p>Wenn die Verwaltung das freigeschaltet hat, trägst du deinen Kaffee direkt auf der
|
||||
Startseite ein. Vertippt? Mit „Letzten Strich stornieren" nimmst du deinen jüngsten
|
||||
<b>selbst</b> eingetragenen Strich zurück – vom Kassenwart erfasste Striche nicht.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Bezahlen</h3>
|
||||
<p>Welche Wege zur Verfügung stehen, legt die Verwaltung fest. Möglich sind:</p>
|
||||
<ul>
|
||||
<li><b>Barzahlung</b> – beim angegebenen Ansprechpartner.</li>
|
||||
<li><b>PayPal</b> – über die Schaltflächen auf der Startseite.</li>
|
||||
<li><b>Überweisung</b> – auf die angegebene IBAN.</li>
|
||||
</ul>
|
||||
<p>Welche Wege offen stehen, legt die Verwaltung fest: Barzahlung, PayPal oder Überweisung.</p>
|
||||
<?php if ($settings !== null): ?>
|
||||
<p><small>In eurem Mandanten sind aktuell aktiv:
|
||||
<p><small>Bei euch aktiv:
|
||||
<?php
|
||||
$wege = [];
|
||||
if ((int)$settings['cash_enabled'] === 1) { $wege[] = 'Barzahlung'; }
|
||||
@@ -77,148 +71,103 @@ include "nav.php";
|
||||
?>.</small></p>
|
||||
<?php endif; ?>
|
||||
<div class="hint-box">
|
||||
<p><b>Wichtig bei PayPal:</b> Nutzt die Funktion „Geld an Freunde senden“. Bei
|
||||
Zahlungen für „Waren und Dienstleistungen“ zieht PayPal Gebühren ab – gutgeschrieben
|
||||
wird dann nur der Betrag, der tatsächlich angekommen ist.</p>
|
||||
<p><b>Wichtig bei PayPal:</b> „Geld an Freunde senden" nutzen. Bei „Waren und
|
||||
Dienstleistungen" zieht PayPal Gebühren ab – gutgeschrieben wird nur, was ankommt.</p>
|
||||
</div>
|
||||
|
||||
<h3>Anzeigenamen ändern</h3>
|
||||
<p>Unter <b>Namensanpassung</b> kannst du festlegen, wie dein Name auf der
|
||||
Kaffeeliste und im Ausdruck erscheint (maximal 20 Zeichen).</p>
|
||||
|
||||
<h3>Kundenkonto</h3>
|
||||
<p>Unter <b>Kundenkonto</b> findest du deine Zugangsdaten, kannst deine E-Mail-Adresse
|
||||
bestätigen und dein Passwort ändern.</p>
|
||||
<h3>Eigene Angaben</h3>
|
||||
<p>Unter <b>Namensanpassung</b> legst du fest, wie dein Name in der Liste und im Ausdruck
|
||||
erscheint (max. 20 Zeichen). Unter <b>Kundenkonto</b> änderst du Passwort und E-Mail.</p>
|
||||
|
||||
<?php if ($istKassenwart): ?>
|
||||
<hr>
|
||||
|
||||
<h2>Für Kassenwart und Verwaltung</h2>
|
||||
<p><small>Sichtbar für die Rollen <b>Kassenwart</b>, <b>Administrator</b> und
|
||||
<b>Inhaber</b>.</small></p>
|
||||
|
||||
<h3>Einzahlung eintragen</h3>
|
||||
<p>Trage hier ein, wer wie viel eingezahlt hat. Du kannst mehrere Mitglieder in
|
||||
einem Durchgang erfassen; leere Felder werden übersprungen.</p>
|
||||
<h3>Buchen</h3>
|
||||
<ul>
|
||||
<li><b>Bemerkung</b>: optionale Notiz, damit später nachvollziehbar bleibt, worum es ging.</li>
|
||||
<li><b>Negativer Betrag</b> = Abzug (z. B. Auszahlung bei Austritt oder eine
|
||||
Erstattung). Dabei ist eine Bemerkung <b>Pflicht</b>.</li>
|
||||
<li><b>Einzahlung eintragen</b>: mehrere Mitglieder in einem Durchgang; leere Felder
|
||||
werden übersprungen. Negativer Betrag = Abzug, dann ist eine Bemerkung Pflicht.</li>
|
||||
<li><b>Striche eintragen</b>: Sammelerfassung für die Papierliste. Der Preis pro Strich
|
||||
kommt aus den Einstellungen und ist pro Vorgang überschreibbar.</li>
|
||||
<li><b>Letzte Einträge</b>: falsche Buchungen hier <b>stornieren</b>, nicht per
|
||||
Gegenbuchung ausgleichen – sonst zählen beide als Umsatz in der Auswertung.</li>
|
||||
</ul>
|
||||
<div class="hint-box">
|
||||
<p><b>Falsch eingetragen ist kein Abzug.</b> Wenn eine Buchung schlicht falsch war,
|
||||
storniere sie unter <b>Letzte Einträge</b>. Ein Gegen-Abzug würde die Auswertung
|
||||
verfälschen, weil dann beide Buchungen als Umsatz zählen.</p>
|
||||
<p>Zum Schutz vor Vertippern werden Beträge über 1.000 € abgelehnt. Schlägt eine
|
||||
Zeile fehl, wird <b>nichts</b> gebucht und deine Eingaben bleiben stehen.</p>
|
||||
</div>
|
||||
|
||||
<h3>Striche eintragen</h3>
|
||||
<p>Sammelerfassung für die Papierliste. Über die Schaltflächen wählst du:</p>
|
||||
<ul>
|
||||
<li><b>Vorderseite</b>: Vieltrinker – Mitglieder mit mindestens 10 Strichen im Listenfenster.</li>
|
||||
<li><b>Rückseite</b>: alle übrigen aktiven Mitglieder.</li>
|
||||
<li><b>Alle</b>: die komplette Liste.</li>
|
||||
</ul>
|
||||
<p>Der <b>Preis pro Strich</b> wird aus den Mandant-Einstellungen vorbelegt und kann
|
||||
für den einzelnen Vorgang überschrieben werden.</p>
|
||||
|
||||
<h3>Letzte Einträge – stornieren</h3>
|
||||
<p>Zeigt die letzten 100 Einzahlungen und Striche. Fehlerhafte Buchungen werden
|
||||
<b>storniert</b>, nicht gelöscht: Sie verschwinden aus der Auswertung, bleiben aber
|
||||
im Journal nachvollziehbar.</p>
|
||||
|
||||
<?php if ($kann('paypal_inbox')): ?>
|
||||
<h3>PayPal-Zahlungen automatisch verbuchen</h3>
|
||||
<p>Die Kaffeeliste kann PayPal-Zahlungen selbstständig gutschreiben:</p>
|
||||
<ol>
|
||||
<li>Auf der Seite <b>PayPal-Zahlungen</b> steht eure persönliche Eingangsadresse.</li>
|
||||
<li>Richtet in eurem Mailprogramm eine <b>automatische Weiterleitung</b> der
|
||||
PayPal-Benachrichtigungen an diese Adresse ein.</li>
|
||||
<li>Erkannte Zahlungen werden dem passenden Mitglied automatisch gutgeschrieben.</li>
|
||||
</ol>
|
||||
<p>Gutgeschrieben wird immer der Betrag, der <b>tatsächlich angekommen</b> ist – bei
|
||||
Waren & Dienstleistungen also nach Abzug der PayPal-Gebühr.</p>
|
||||
<div class="hint-box">
|
||||
<p>Zahlungen, die sich nicht eindeutig zuordnen lassen, landen in der Warteschlange
|
||||
„Offene Zahlungen“ und können dort von Hand einem Mitglied zugeordnet oder ignoriert
|
||||
werden. Damit die automatische Zuordnung möglichst oft greift, hinterlege den
|
||||
<b>PayPal-Namen</b> beim Mitglied.</p>
|
||||
</div>
|
||||
<p>Auf der Seite <b>PayPal-Zahlungen</b> steht eure Eingangsadresse. Leitet die
|
||||
PayPal-Benachrichtigungen aus eurem Postfach automatisch dorthin weiter, dann werden
|
||||
erkannte Zahlungen selbstständig gutgeschrieben – immer der tatsächlich angekommene
|
||||
Betrag. Nicht eindeutig zuordenbare Zahlungen landen in der Warteschlange „Offene
|
||||
Zahlungen". Ein hinterlegter <b>PayPal-Name</b> beim Mitglied verbessert die Trefferquote.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>CSV-Import</h3>
|
||||
<p>Über <b>CSV Upload</b> lassen sich Einzahlungen aus einer Datei einlesen. Der Import
|
||||
zeigt zuerst eine <b>Vorschau</b>; erst danach wird gebucht. Zeilen ohne eindeutige
|
||||
Zuordnung werden markiert und nicht verbucht.</p>
|
||||
|
||||
<h3>Kaffeeliste, Ausdruck und Auswertung</h3>
|
||||
<h3>Auswertung und Ausdruck</h3>
|
||||
<ul>
|
||||
<li><b>Kaffeeliste anzeigen</b>: Gesamtübersicht aller aktiven Mitglieder. Ein Klick
|
||||
auf den Namen öffnet die Einzelauswertung.</li>
|
||||
<li><b>PDF-Export</b>: die Liste zum Ausdrucken und Aufhängen.</li>
|
||||
<li><b>Journal-Vorschau</b>: alle Buchungen mit Herkunft – nützlich zur Kontrolle.</li>
|
||||
<li><b>Kaffeeliste anzeigen</b>: Gesamtübersicht; ein Klick auf den Namen öffnet die
|
||||
Einzelauswertung.</li>
|
||||
<?php if ($kann('pdf_export')): ?>
|
||||
<li><b>PDF-Export</b>: die Liste zum Aufhängen. Bis 50 Mitglieder eine Seite, darüber
|
||||
Vorder- und Rückseite.</li>
|
||||
<?php endif; ?>
|
||||
<li><b>Journal-Vorschau</b>: alle Buchungen mit Herkunft.</li>
|
||||
<?php if ($kann('csv_import')): ?>
|
||||
<li><b>CSV Upload</b>: Einzahlungen aus einer Datei einlesen – erst Vorschau, dann
|
||||
buchen. Zeilen ohne eindeutige Zuordnung werden nicht verbucht.</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
|
||||
<h3>Info-Mails versenden</h3>
|
||||
<p>Unter <b>Mailversand</b> lassen sich Mitglieder über ihren Stand informieren. Der
|
||||
<b>Dry-Run</b> zeigt zuerst, wer eine Mail bekäme, ohne etwas zu verschicken. Das
|
||||
Versandlog hält fest, was tatsächlich rausging.</p>
|
||||
|
||||
<h3>Jahresabschluss</h3>
|
||||
<p>Verteilt einen frei wählbaren Gesamtbetrag als Guthaben auf alle Mitglieder –
|
||||
anteilig zu ihren Strichen im laufenden Jahr. Auch hier gilt: erst <b>Dry-Run</b>
|
||||
zur Vorschau, dann buchen. Beim echten Lauf werden die Mitglieder per Mail informiert.</p>
|
||||
<?php if ($kann('mail_dispatch') || $kann('year_closing')): ?>
|
||||
<h3>Mails und Jahresabschluss</h3>
|
||||
<ul>
|
||||
<?php if ($kann('mail_dispatch')): ?>
|
||||
<li><b>Info-Mail versenden</b>: informiert Mitglieder über ihren Stand. Der Dry-Run
|
||||
zeigt vorher, wer eine Mail bekäme.</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($kann('year_closing')): ?>
|
||||
<li><b>Jahresabschluss</b>: verteilt einen frei wählbaren Betrag anteilig zu den
|
||||
Strichen des Jahres als Guthaben. Auch hier erst Dry-Run, dann buchen.</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($istAdmin): ?>
|
||||
<hr>
|
||||
|
||||
<h2>Für Administratoren</h2>
|
||||
<p><small>Sichtbar für die Rollen <b>Administrator</b> und <b>Inhaber</b>.</small></p>
|
||||
|
||||
<h3>Mitglieder verwalten</h3>
|
||||
<ul>
|
||||
<li><b>Anlegen und bearbeiten</b> von Mitgliedern (Name, E-Mail, PayPal-Name).</li>
|
||||
<li><b>PayPal-Name</b>: entscheidend für die automatische Zuordnung von Zahlungen.</li>
|
||||
<li><b>Deaktivieren</b> statt löschen: Das Mitglied verschwindet aus den Listen,
|
||||
die Buchungshistorie bleibt erhalten.</li>
|
||||
<li><b>Anonymisieren</b>: ersetzt Name und E-Mail durch einen Platzhalter (DSGVO-Auskunft),
|
||||
die Buchungen der Kasse bleiben für die Abrechnung bestehen.</li>
|
||||
<li><b>Zugang gewähren / entziehen</b>: steuert, ob ein Mitglied sich anmelden kann.</li>
|
||||
<li><b>CSV-Import</b> für viele Mitglieder auf einmal – eine passende Vorlage gibt es
|
||||
zum Herunterladen.</li>
|
||||
<li><b>Deaktivieren</b> statt löschen: das Mitglied verschwindet aus den Listen, die
|
||||
Buchungshistorie bleibt.</li>
|
||||
<li><b>Anonymisieren</b>: ersetzt Name und E-Mail durch einen Platzhalter, die Buchungen
|
||||
der Kasse bleiben bestehen.</li>
|
||||
<li><b>Zugang gewähren / entziehen</b> steuert, ob sich jemand anmelden kann.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Hinweise verwalten</h3>
|
||||
<p>Ein Hinweis erscheint als gelber Balken oben auf jeder Seite – z. B. „Kaffeekasse
|
||||
ist leer“. Jeder Hinweis hat ein <b>Gültig bis</b>-Datum und verschwindet danach
|
||||
automatisch.</p>
|
||||
<h3>Hinweise</h3>
|
||||
<p>Ein Hinweis erscheint als gelber Balken oben auf jeder Seite und verschwindet nach dem
|
||||
<b>Gültig bis</b>-Datum automatisch.</p>
|
||||
|
||||
<h3>Mandant-Einstellungen</h3>
|
||||
<p>Hier stellst du das Verhalten der Kaffeeliste ein:</p>
|
||||
<ul>
|
||||
<li><b>Preis pro Strich</b> – Grundlage jeder Verbrauchsbuchung.</li>
|
||||
<li><b>Listenfenster in Tagen</b> – Zeitraum für die Vorder-/Rückseiten-Aufteilung.</li>
|
||||
<li><b>Schulden-Warnschwelle</b> – ab welchem Minus gewarnt bzw. erinnert wird.</li>
|
||||
<li><b>Bezahloptionen</b> – Barzahlung, PayPal und Überweisung samt Kontaktdaten.
|
||||
Nur aktivierte Wege erscheinen bei den Mitgliedern.</li>
|
||||
<li><b>Web-Striche erlauben</b> – ob Mitglieder selbst eintragen dürfen.</li>
|
||||
<li><b>Ausdruck</b> – Sortierung, Zeilenhöhe, leere Zeilen, Wasserzeichen und Fußzeile.</li>
|
||||
<li><b>Zahlungserinnerungen</b> – automatische Mails an Mitglieder unterhalb der
|
||||
Warnschwelle, samt Mindestabstand zwischen zwei Erinnerungen.</li>
|
||||
</ul>
|
||||
<p>Im <b>Protokoll</b> unten auf der Seite steht, wer wann welche Änderung vorgenommen hat.</p>
|
||||
<p>Preis pro Strich, Listenfenster, Warnschwelle, Bezahloptionen, Web-Striche, Gestaltung
|
||||
des Ausdrucks<?php echo $kann('branding') ? ', eigenes Design (Logo und Akzentfarbe)' : ''; ?>
|
||||
und Zahlungserinnerungen. Unten steht das Protokoll aller Admin-Änderungen.</p>
|
||||
|
||||
<h3>Konto, Abo und Daten</h3>
|
||||
<ul>
|
||||
<li><b>Kundenkonto</b>: Tarif, Rechnungen und Zahlungsdaten.</li>
|
||||
<li><b>Datenexport</b>: alle Daten des Mandanten als Datei – für ein Backup oder
|
||||
einen Umzug.</li>
|
||||
<li><b>Mandant löschen</b>: entfernt den Mandanten mit allen Daten endgültig.</li>
|
||||
<?php if ($kann('data_export')): ?>
|
||||
<li><b>Datenexport</b>: alle Daten des Mandanten als Datei.</li>
|
||||
<?php endif; ?>
|
||||
<li><b>Mandant löschen</b>: entfernt alles endgültig<?php echo $kann('data_export') ? ' – vorher Datenexport ziehen' : ''; ?>.</li>
|
||||
</ul>
|
||||
<div class="hint-box error">
|
||||
<p><b>Das Löschen eines Mandanten kann nicht rückgängig gemacht werden.</b> Lade dir
|
||||
vorher über den Datenexport eine Kopie herunter.</p>
|
||||
</div>
|
||||
|
||||
<h3>Rollen im Überblick</h3>
|
||||
<table>
|
||||
@@ -231,11 +180,10 @@ include "nav.php";
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($kann('faq')): ?>
|
||||
<hr>
|
||||
|
||||
<h3>Noch Fragen?</h3>
|
||||
<p>Häufige Fragen und die Antworten eurer Verwaltung stehen unter
|
||||
<a href="faq.php">FAQ Kaffeeliste</a>.</p>
|
||||
<p>Weitere Fragen beantwortet eure Verwaltung unter <a href="faq.php">FAQ Kaffeeliste</a>.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/analytics.php';
|
||||
|
||||
/**
|
||||
* Der Browser spricht fuer die Analyse nur mit der bereits besuchten
|
||||
* Kaffeeliste-Domain. Dieser Baustein validiert die wenigen erlaubten Werte
|
||||
* und sendet sie anschliessend an die fest konfigurierte Matomo-Site.
|
||||
* Insbesondere werden weder die Client-IP noch Benutzer- oder Formulardaten
|
||||
* uebernommen.
|
||||
*/
|
||||
|
||||
/** @param array<string, string> $cookies */
|
||||
function app_analytics_consent_is_granted(array $cookies): bool
|
||||
{
|
||||
return hash_equals(
|
||||
APP_ANALYTICS_CONSENT_VERSION . ':granted',
|
||||
(string)($cookies[APP_ANALYTICS_CONSENT_COOKIE] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $server */
|
||||
function app_analytics_request_origin_is_valid(array $server): bool
|
||||
{
|
||||
$fetchSite = strtolower(trim((string)($server['HTTP_SEC_FETCH_SITE'] ?? '')));
|
||||
if ($fetchSite !== '' && $fetchSite !== 'same-origin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$origin = trim((string)($server['HTTP_ORIGIN'] ?? ''));
|
||||
$requestHost = trim((string)($server['HTTP_HOST'] ?? ''));
|
||||
if ($origin === '' || $requestHost === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$originParts = parse_url($origin);
|
||||
$requestParts = parse_url('http://' . $requestHost);
|
||||
if (!is_array($originParts) || !is_array($requestParts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$originScheme = strtolower((string)($originParts['scheme'] ?? ''));
|
||||
$expectedScheme = (!empty($server['HTTPS']) && $server['HTTPS'] !== 'off')
|
||||
|| (string)($server['SERVER_PORT'] ?? '') === '443'
|
||||
? 'https'
|
||||
: 'http';
|
||||
|
||||
return $originScheme === $expectedScheme
|
||||
&& strtolower((string)($originParts['host'] ?? '')) === strtolower((string)($requestParts['host'] ?? ''))
|
||||
&& (int)($originParts['port'] ?? ($originScheme === 'https' ? 443 : 80))
|
||||
=== (int)($requestParts['port'] ?? ($expectedScheme === 'https' ? 443 : 80));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function app_analytics_allowed_hosts(string $requestHost): array
|
||||
{
|
||||
$hosts = app_is_dev() ? [$requestHost] : [];
|
||||
$appHost = trim((string)app_primary_host());
|
||||
if ($appHost !== '') {
|
||||
$hosts[] = strtolower($appHost);
|
||||
}
|
||||
$marketingHost = strtolower((string)parse_url((string)app_env('APP_MARKETING_URL', ''), PHP_URL_HOST));
|
||||
if ($marketingHost !== '') {
|
||||
$hosts[] = $marketingHost;
|
||||
}
|
||||
|
||||
return array_values(array_unique($hosts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt Query und Fragment nochmals serverseitig und erlaubt nur die
|
||||
* konfigurierten Kaffeeliste-Hosts. Bei der aktuellen Seite muss der Host
|
||||
* zusaetzlich dem Host des First-Party-Requests entsprechen.
|
||||
*/
|
||||
function app_analytics_clean_url(string $value, string $requestHost, bool $isCurrentPage): ?string
|
||||
{
|
||||
if ($value === '' || strlen($value) > 2048 || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
if (
|
||||
!is_array($parts)
|
||||
|| !in_array(strtolower((string)($parts['scheme'] ?? '')), ['http', 'https'], true)
|
||||
|| trim((string)($parts['host'] ?? '')) === ''
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = strtolower((string)$parts['host']);
|
||||
$requestParts = parse_url('http://' . $requestHost);
|
||||
$normalizedRequestHost = strtolower((string)($requestParts['host'] ?? ''));
|
||||
if ($normalizedRequestHost === '') {
|
||||
return null;
|
||||
}
|
||||
if ($isCurrentPage && $host !== $normalizedRequestHost) {
|
||||
return null;
|
||||
}
|
||||
if (!in_array($host, app_analytics_allowed_hosts($normalizedRequestHost), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!app_is_dev() && $scheme !== 'https') {
|
||||
return null;
|
||||
}
|
||||
if ($isCurrentPage) {
|
||||
$pagePort = (int)($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
|
||||
$requestPort = (int)($requestParts['port'] ?? ($scheme === 'https' ? 443 : 80));
|
||||
if ($pagePort !== $requestPort) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
|
||||
$path = (string)($parts['path'] ?? '/');
|
||||
if ($path === '' || !str_starts_with($path, '/')) {
|
||||
$path = '/' . $path;
|
||||
}
|
||||
|
||||
return $scheme . '://' . $host . $port . $path;
|
||||
}
|
||||
|
||||
function app_analytics_clean_text(string $value, int $maximumLength): string
|
||||
{
|
||||
$value = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', strip_tags($value)) ?? '');
|
||||
return mb_substr($value, 0, $maximumLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $server
|
||||
* @param array<string, string> $cookies
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function app_matomo_page_view_parameters(array $payload, array $server, array $cookies): array
|
||||
{
|
||||
if (!app_analytics_consent_is_granted($cookies)) {
|
||||
throw new RuntimeException('Keine Analyse-Einwilligung.');
|
||||
}
|
||||
if (!app_analytics_request_origin_is_valid($server)) {
|
||||
throw new RuntimeException('Ungültiger Anfrageursprung.');
|
||||
}
|
||||
|
||||
$visitorId = strtolower(trim((string)($payload['visitorId'] ?? '')));
|
||||
$visitorCookie = strtolower(trim((string)($cookies[APP_ANALYTICS_VISITOR_COOKIE] ?? '')));
|
||||
if (preg_match('/^[0-9a-f]{16}$/', $visitorId) !== 1 || !hash_equals($visitorCookie, $visitorId)) {
|
||||
throw new RuntimeException('Ungültige Analyse-Besucherkennung.');
|
||||
}
|
||||
|
||||
$requestHost = trim((string)($server['HTTP_HOST'] ?? ''));
|
||||
$pageUrl = app_analytics_clean_url((string)($payload['pageUrl'] ?? ''), $requestHost, true);
|
||||
if ($pageUrl === null) {
|
||||
throw new RuntimeException('Ungültige Seiten-URL.');
|
||||
}
|
||||
|
||||
$pageTitle = app_analytics_clean_text((string)($payload['pageTitle'] ?? ''), 200);
|
||||
if ($pageTitle === '') {
|
||||
$pageTitle = 'Kaffeeliste';
|
||||
}
|
||||
|
||||
$parameters = [
|
||||
'rec' => '1',
|
||||
'apiv' => '1',
|
||||
'send_image' => '0',
|
||||
'action_name' => $pageTitle,
|
||||
'url' => $pageUrl,
|
||||
'_id' => $visitorId,
|
||||
'rand' => (string)random_int(100000, 999999999),
|
||||
'cookie' => '1',
|
||||
];
|
||||
|
||||
$referrer = app_analytics_clean_url((string)($payload['referrerUrl'] ?? ''), $requestHost, false);
|
||||
if ($referrer !== null) {
|
||||
$parameters['urlref'] = $referrer;
|
||||
}
|
||||
|
||||
$resolution = trim((string)($payload['resolution'] ?? ''));
|
||||
if (preg_match('/^[1-9][0-9]{0,4}x[1-9][0-9]{0,4}$/', $resolution) === 1) {
|
||||
$parameters['res'] = $resolution;
|
||||
}
|
||||
|
||||
$language = app_analytics_clean_text((string)($payload['language'] ?? ''), 35);
|
||||
if ($language !== '' && preg_match('/^[A-Za-z0-9._-]+$/', $language) === 1) {
|
||||
$parameters['lang'] = $language;
|
||||
}
|
||||
|
||||
foreach (['h' => 23, 'm' => 59, 's' => 59] as $name => $maximum) {
|
||||
$value = filter_var($payload[$name] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 0, 'max_range' => $maximum],
|
||||
]);
|
||||
if ($value !== false) {
|
||||
$parameters[$name] = (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
// Der User-Agent ist vom Einwilligungstext umfasst. Die Client-IP wird
|
||||
// bewusst nicht als Tracking-IP an Matomo weitergegeben.
|
||||
$userAgent = app_analytics_clean_text((string)($server['HTTP_USER_AGENT'] ?? ''), 500);
|
||||
if ($userAgent !== '') {
|
||||
$parameters['ua'] = $userAgent;
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $parameters
|
||||
* @param null|callable(string, array<string, string>): array{ok: bool, status: int, error: ?string} $transport
|
||||
* @return array{ok: bool, status: int, error: ?string}
|
||||
*/
|
||||
function app_matomo_forward_page_view(array $parameters, ?callable $transport = null): array
|
||||
{
|
||||
$configuration = app_matomo_configuration();
|
||||
if ($configuration === null) {
|
||||
return ['ok' => false, 'status' => 0, 'error' => 'Matomo ist nicht konfiguriert.'];
|
||||
}
|
||||
|
||||
$parameters['idsite'] = $configuration['siteId'];
|
||||
$trackerUrl = $configuration['matomoUrl'] . 'matomo.php';
|
||||
if ($transport !== null) {
|
||||
return $transport($trackerUrl, $parameters);
|
||||
}
|
||||
|
||||
$body = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($trackerUrl);
|
||||
if ($curl === false) {
|
||||
return ['ok' => false, 'status' => 0, 'error' => 'cURL konnte nicht initialisiert werden.'];
|
||||
}
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => 6,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
$response = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = $response === false ? curl_error($curl) : null;
|
||||
curl_close($curl);
|
||||
|
||||
return [
|
||||
'ok' => $response !== false && $status >= 200 && $status < 300,
|
||||
'status' => $status,
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/x-www-form-urlencoded\r\nConnection: close",
|
||||
'content' => $body,
|
||||
'timeout' => 6,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$response = @file_get_contents($trackerUrl, false, $context);
|
||||
$status = 0;
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $matches) === 1) {
|
||||
$status = (int)$matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $response !== false && $status >= 200 && $status < 300,
|
||||
'status' => $status,
|
||||
'error' => $response === false ? 'Keine Antwort vom Matomo-Endpunkt.' : null,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
const APP_ANALYTICS_CONSENT_COOKIE = 'kaffeeliste_analytics_consent';
|
||||
const APP_ANALYTICS_VISITOR_COOKIE = 'kaffeeliste_analytics_visitor';
|
||||
const APP_ANALYTICS_CONSENT_VERSION = '2';
|
||||
const APP_ANALYTICS_CONSENT_MAX_AGE = 15552000; // 180 Tage
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function app_matomo_configuration_errors(): array
|
||||
{
|
||||
$url = trim((string)app_env('MATOMO_URL', ''));
|
||||
$siteId = trim((string)app_env('MATOMO_SITE_ID', ''));
|
||||
$privacyConfirmed = trim((string)app_env('MATOMO_PRIVACY_CONFIRMED', ''));
|
||||
$cookieDomain = trim((string)app_env('MATOMO_CONSENT_COOKIE_DOMAIN', ''));
|
||||
$hasAnySetting = $url !== '' || $siteId !== '' || $cookieDomain !== '';
|
||||
|
||||
if (!$hasAnySetting) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$urlParts = $url !== '' ? parse_url($url) : false;
|
||||
if (
|
||||
$url === ''
|
||||
|| filter_var($url, FILTER_VALIDATE_URL) === false
|
||||
|| !is_array($urlParts)
|
||||
|| !in_array(strtolower((string)($urlParts['scheme'] ?? '')), ['http', 'https'], true)
|
||||
|| trim((string)($urlParts['host'] ?? '')) === ''
|
||||
|| isset($urlParts['user'])
|
||||
|| isset($urlParts['pass'])
|
||||
|| isset($urlParts['query'])
|
||||
|| isset($urlParts['fragment'])
|
||||
) {
|
||||
$errors[] = 'MATOMO_URL muss eine vollständige HTTP(S)-Basis-URL ohne Query oder Fragment sein.';
|
||||
} elseif (!app_is_dev() && strtolower((string)$urlParts['scheme']) !== 'https') {
|
||||
$errors[] = 'MATOMO_URL muss außerhalb der Entwicklungsumgebung HTTPS verwenden.';
|
||||
}
|
||||
|
||||
if (preg_match('/^[1-9][0-9]*$/', $siteId) !== 1) {
|
||||
$errors[] = 'MATOMO_SITE_ID muss eine positive ganze Zahl sein.';
|
||||
}
|
||||
|
||||
if ($privacyConfirmed !== '1') {
|
||||
$errors[] = 'MATOMO_PRIVACY_CONFIRMED muss nach Prüfung der Matomo-Datenschutzeinstellungen auf 1 gesetzt werden.';
|
||||
}
|
||||
|
||||
$cookieDomainHostname = ltrim(strtolower($cookieDomain), '.');
|
||||
if ($cookieDomain !== '' && filter_var($cookieDomainHostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) {
|
||||
$errors[] = 'MATOMO_CONSENT_COOKIE_DOMAIN ist keine gültige Cookie-Domain.';
|
||||
} elseif ($cookieDomain !== '') {
|
||||
$hostBelongsToCookieDomain = static function (string $host) use ($cookieDomainHostname): bool {
|
||||
$host = strtolower($host);
|
||||
return $host === $cookieDomainHostname || str_ends_with($host, '.' . $cookieDomainHostname);
|
||||
};
|
||||
$appHost = trim((string)app_primary_host());
|
||||
$marketingHost = (string)parse_url((string)app_env('APP_MARKETING_URL', ''), PHP_URL_HOST);
|
||||
if ($appHost !== '' && !$hostBelongsToCookieDomain($appHost)) {
|
||||
$errors[] = 'MATOMO_CONSENT_COOKIE_DOMAIN passt nicht zu APP_HOST.';
|
||||
}
|
||||
if ($marketingHost !== '' && !$hostBelongsToCookieDomain($marketingHost)) {
|
||||
$errors[] = 'MATOMO_CONSENT_COOKIE_DOMAIN passt nicht zu APP_MARKETING_URL.';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{matomoUrl: string, siteId: string, consentCookie: string, visitorCookie: string, consentVersion: string, consentMaxAge: int, cookieDomain: string, secureCookies: bool}|null
|
||||
*/
|
||||
function app_matomo_configuration(): ?array
|
||||
{
|
||||
$url = trim((string)app_env('MATOMO_URL', ''));
|
||||
$siteId = trim((string)app_env('MATOMO_SITE_ID', ''));
|
||||
$privacyConfirmed = trim((string)app_env('MATOMO_PRIVACY_CONFIRMED', ''));
|
||||
|
||||
if ($url === '' && $siteId === '') {
|
||||
return null;
|
||||
}
|
||||
if (app_matomo_configuration_errors() !== []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'matomoUrl' => rtrim($url, '/') . '/',
|
||||
'siteId' => $siteId,
|
||||
'consentCookie' => APP_ANALYTICS_CONSENT_COOKIE,
|
||||
'visitorCookie' => APP_ANALYTICS_VISITOR_COOKIE,
|
||||
'consentVersion' => APP_ANALYTICS_CONSENT_VERSION,
|
||||
'consentMaxAge' => APP_ANALYTICS_CONSENT_MAX_AGE,
|
||||
'cookieDomain' => trim((string)app_env('MATOMO_CONSENT_COOKIE_DOMAIN', '')),
|
||||
'secureCookies' => app_is_https(),
|
||||
];
|
||||
}
|
||||
|
||||
function app_analytics_head_html(): string
|
||||
{
|
||||
if (app_matomo_configuration() === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<link rel="stylesheet" href="assets/css/privacy-settings.css">' . "\n";
|
||||
}
|
||||
|
||||
function app_analytics_footer_settings_button_html(): string
|
||||
{
|
||||
if (app_matomo_configuration() === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<button type="button" class="footer-cookie-settings" data-analytics-settings>'
|
||||
. 'Cookie-Einstellungen</button>';
|
||||
}
|
||||
|
||||
function app_analytics_body_html(): string
|
||||
{
|
||||
$configuration = app_matomo_configuration();
|
||||
if ($configuration === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Matomo-Host und Site-ID bleiben serverseitig. Der Browser sendet nach
|
||||
// Einwilligung nur an den First-Party-Endpunkt; dieser leitet die eng
|
||||
// validierte Seitenansicht an die fest konfigurierte Matomo-Site weiter.
|
||||
$browserConfiguration = [
|
||||
'consentCookie' => $configuration['consentCookie'],
|
||||
'visitorCookie' => $configuration['visitorCookie'],
|
||||
'consentVersion' => $configuration['consentVersion'],
|
||||
'consentMaxAge' => $configuration['consentMaxAge'],
|
||||
'cookieDomain' => $configuration['cookieDomain'],
|
||||
'secureCookies' => $configuration['secureCookies'],
|
||||
'trackingEndpoint' => 'nutzungsanalyse.php',
|
||||
];
|
||||
|
||||
$json = json_encode(
|
||||
$browserConfiguration,
|
||||
JSON_UNESCAPED_SLASHES
|
||||
| JSON_UNESCAPED_UNICODE
|
||||
| JSON_HEX_TAG
|
||||
| JSON_HEX_AMP
|
||||
| JSON_HEX_APOS
|
||||
| JSON_HEX_QUOT
|
||||
);
|
||||
if (!is_string($json)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return <<<'HTML'
|
||||
<section class="analytics-consent" id="analytics-consent" aria-labelledby="analytics-consent-title" aria-describedby="analytics-consent-description" hidden>
|
||||
<div class="analytics-consent__inner">
|
||||
<div class="analytics-consent__copy">
|
||||
<h2 id="analytics-consent-title">Cookie-Zustimmung</h2>
|
||||
<p id="analytics-consent-description">Wir möchten mit Matomo verstehen, wie unsere Website und App genutzt werden. Erst nach deiner Zustimmung übermitteln wir Seitenaufrufe an unser selbst betriebenes Matomo und setzen ein Analyse-Cookie. Notwendige Cookies funktionieren immer. Mehr dazu steht im <a href="datenschutz.php">Datenschutz</a>.</p>
|
||||
</div>
|
||||
<div class="analytics-consent__actions">
|
||||
<button type="button" data-analytics-consent="denied">Nur notwendige Cookies</button>
|
||||
<button type="button" data-analytics-consent="granted">Zustimmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
HTML
|
||||
. "\n<script type=\"application/json\" id=\"analytics-configuration\">{$json}</script>\n"
|
||||
. '<script src="assets/js/privacy-settings.js" defer></script>' . "\n";
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function app_audit_log(
|
||||
?int $subjectId,
|
||||
array $metadata = []
|
||||
): void {
|
||||
$pdo->exec('DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO audit_log (tenant_id, actor_user_id, action, subject_type, subject_id, metadata_json, ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
|
||||
@@ -99,6 +99,33 @@ function app_primary_url(string $path): string
|
||||
return $scheme . '://' . $host . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse der Marketing-Domain (z. B. https://kaffeeliste.de) - Ziel des
|
||||
* Wasserzeichens im kostenlosen Tarif. Explizit ueber APP_MARKETING_URL
|
||||
* setzbar; sonst wird sie aus APP_HOST abgeleitet, indem die
|
||||
* App-Subdomain wegfaellt (app.kaffeeliste.de -> kaffeeliste.de). In der
|
||||
* lokalen Entwicklung ohne APP_HOST bleibt der aktuelle Host uebrig, im
|
||||
* CLI-Betrieb ein leerer String.
|
||||
*/
|
||||
function app_marketing_url(): string
|
||||
{
|
||||
$configured = app_env('APP_MARKETING_URL');
|
||||
if ($configured !== null) {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
|
||||
$scheme = app_is_https() ? 'https' : 'http';
|
||||
$host = app_primary_host();
|
||||
|
||||
if ($host !== null) {
|
||||
return $scheme . '://' . preg_replace('/^app\./i', '', $host);
|
||||
}
|
||||
|
||||
$requestHost = (string)($_SERVER['HTTP_HOST'] ?? '');
|
||||
|
||||
return $requestHost !== '' ? $scheme . '://' . $requestHost : '';
|
||||
}
|
||||
|
||||
function app_start_session(): void
|
||||
{
|
||||
if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
|
||||
@@ -176,3 +203,8 @@ function app_require_csrf(): void
|
||||
}
|
||||
|
||||
app_send_security_headers();
|
||||
|
||||
// Der Analyse-Baustein wird zentral geladen, damit alle HTML-Templates nur
|
||||
// noch die beiden bewusst platzierten Head-/Body-Fragmente ausgeben muessen.
|
||||
// analytics.php bindet bootstrap.php seinerseits per require_once ein.
|
||||
require_once __DIR__ . '/analytics.php';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
require_once __DIR__ . '/features.php';
|
||||
|
||||
/**
|
||||
* Logo und Akzentfarbe des Mandanten, an dem der angemeldete Nutzer haengt.
|
||||
* Ohne Anmeldung, ohne freigeschaltete Funktion „Eigenes Design" oder ohne
|
||||
* hinterlegte Werte kommt ueberall ein leerer String zurueck - der Aufrufer
|
||||
* bleibt dann beim Standarddesign.
|
||||
*
|
||||
* Das Ergebnis wird pro Request gecached: header.php laeuft auf jeder Seite
|
||||
* und soll dafuer nicht mehrfach die Einstellungen laden.
|
||||
*
|
||||
* @return array{color: string, logo_url: string, tenant_name: string}
|
||||
*/
|
||||
function app_branding_for_current_user(PDO $pdo): array
|
||||
{
|
||||
static $cached = null;
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$leer = ['color' => '', 'logo_url' => '', 'tenant_name' => ''];
|
||||
|
||||
try {
|
||||
$user = saas_current_user($pdo);
|
||||
if ($user === null) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$tenantId = (int)$user['tenant_id'];
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'branding')) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
if ($settings === null) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$color = (string)($settings['brand_color'] ?? '');
|
||||
// Zweite Pruefung neben der Formularvalidierung: was hier nicht wie
|
||||
// ein Hex-Wert aussieht, wird nie in ein Stylesheet geschrieben.
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||||
$color = '';
|
||||
}
|
||||
|
||||
return $cached = [
|
||||
'color' => strtolower($color),
|
||||
'logo_url' => trim((string)($settings['brand_logo'] ?? '')) !== '' ? 'tenant-logo-anzeigen.php' : '',
|
||||
'tenant_name' => (string)($settings['name'] ?? ''),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
// Branding ist Beiwerk: faellt es aus (z. B. Migration noch nicht
|
||||
// eingespielt), laedt die Seite trotzdem im Standarddesign.
|
||||
return $cached = $leer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die Farbueberschreibungen fuer das Template-Stylesheet. main.css
|
||||
* verwendet durchgaengig genau einen Akzentton (#38761d) - hier werden die
|
||||
* sichtbaren Stellen davon auf die Mandantenfarbe umgestellt.
|
||||
*/
|
||||
function app_branding_css(string $color): string
|
||||
{
|
||||
$c = preg_match('/^#[0-9a-f]{6}$/', strtolower($color)) ? strtolower($color) : '#38761d';
|
||||
|
||||
return <<<CSS
|
||||
:root { --brand-color: {$c}; }
|
||||
a { color: var(--brand-color); }
|
||||
a:hover { border-bottom-color: var(--brand-color); color: var(--brand-color) !important; }
|
||||
header.major > :last-child { border-bottom-color: var(--brand-color); }
|
||||
input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus,
|
||||
input[type="tel"]:focus, input[type="search"]:focus, input[type="url"]:focus,
|
||||
input[type="number"]:focus, input[type="date"]:focus, input[type="datetime-local"]:focus,
|
||||
select:focus, textarea:focus,
|
||||
input[type="checkbox"]:focus + label:before, input[type="radio"]:focus + label:before {
|
||||
border-color: var(--brand-color);
|
||||
box-shadow: 0 0 0 1px var(--brand-color);
|
||||
}
|
||||
input[type="checkbox"]:checked + label:before, input[type="radio"]:checked + label:before {
|
||||
background: var(--brand-color);
|
||||
border-color: var(--brand-color);
|
||||
}
|
||||
input[type="submit"], input[type="reset"], input[type="button"], button, .button {
|
||||
box-shadow: inset 0 0 0 2px var(--brand-color);
|
||||
color: var(--brand-color) !important;
|
||||
}
|
||||
input[type="submit"].primary, input[type="reset"].primary, input[type="button"].primary,
|
||||
button.primary, .button.primary {
|
||||
background-color: var(--brand-color);
|
||||
box-shadow: none;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
#header { border-bottom-color: var(--brand-color); }
|
||||
#menu ul a:hover, #menu ul span:hover { color: var(--brand-color); }
|
||||
.tenant-brand-line { border-bottom: solid 1px rgba(0, 0, 0, 0.08); }
|
||||
|
||||
CSS;
|
||||
}
|
||||
+54
-1
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/tenant-logo.php';
|
||||
|
||||
/**
|
||||
* Assembles a full export of everything stored for one tenant (data
|
||||
@@ -13,7 +14,7 @@ require_once __DIR__ . '/bootstrap.php';
|
||||
*/
|
||||
function app_export_tenant_data(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id, slug, name, status, timezone, locale, currency_code, created_at, updated_at FROM tenants WHERE id = ?');
|
||||
$stmt = $pdo->prepare('SELECT id, slug, paypal_inbox_token, name, customer_type, status, timezone, locale, currency_code, created_at, updated_at FROM tenants WHERE id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$tenant = $stmt->fetch();
|
||||
|
||||
@@ -66,6 +67,51 @@ function app_export_tenant_data(PDO $pdo, int $tenantId): array
|
||||
$stmt->execute([$tenantId]);
|
||||
$auditLog = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM faq_entries WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$faqEntries = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM paypal_payments WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$paypalPayments = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM tenant_billing WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$billing = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM tenant_features WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$features = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT document_type, document_version, context, metadata_json, accepted_at
|
||||
FROM legal_acceptances WHERE tenant_id = ? ORDER BY id'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$legalAcceptances = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT reference_code, request_type, requester_name, requester_email,
|
||||
contract_reference, request_reason, requested_end, status,
|
||||
metadata_json, received_at, processed_at
|
||||
FROM legal_requests WHERE tenant_id = ? ORDER BY id'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$legalRequests = $stmt->fetchAll();
|
||||
|
||||
$logos = [];
|
||||
foreach (['brand_logo', 'pdf_watermark_logo'] as $logoField) {
|
||||
$filename = (string)($settings[$logoField] ?? '');
|
||||
$path = tenant_logo_path($filename);
|
||||
if ($path !== null) {
|
||||
$logos[$logoField] = [
|
||||
'filename' => $filename,
|
||||
'mime_type' => mime_content_type($path) ?: 'application/octet-stream',
|
||||
'base64' => base64_encode((string)file_get_contents($path)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'exported_at' => date('c'),
|
||||
'tenant' => $tenant ?: null,
|
||||
@@ -78,5 +124,12 @@ function app_export_tenant_data(PDO $pdo, int $tenantId): array
|
||||
'payment_import_rows' => $importRows,
|
||||
'outbound_emails' => $outboundEmails,
|
||||
'audit_log' => $auditLog,
|
||||
'faq_entries' => $faqEntries,
|
||||
'paypal_payments' => $paypalPayments,
|
||||
'billing' => $billing ?: null,
|
||||
'features' => $features,
|
||||
'legal_acceptances' => $legalAcceptances,
|
||||
'legal_requests' => $legalRequests,
|
||||
'logos' => $logos,
|
||||
];
|
||||
}
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/prefixed-pdo.php';
|
||||
|
||||
function app_db_pdo(): PDO
|
||||
{
|
||||
@@ -23,11 +24,12 @@ function app_db_pdo(): PDO
|
||||
}
|
||||
|
||||
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
|
||||
$pdo = new PDO($dsn, $user, $password ?? '', [
|
||||
$pdo = new AppPrefixedPDO($dsn, $user, $password ?? '', [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => true,
|
||||
]);
|
||||
$pdo->setTablePrefix(app_db_table_prefix());
|
||||
|
||||
// DB-Session auf denselben Offset wie die (in bootstrap.php gepinnte)
|
||||
// PHP-Zeitzone setzen, damit MySQL NOW() und PHP-Zeitstempel
|
||||
|
||||
+7
-8
@@ -65,18 +65,17 @@ function faq_soft_delete(PDO $pdo, int $tenantId, int $id): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a small, generic starter FAQ for a brand-new tenant (mirrors the
|
||||
* excerpt already shown on the public landing page), so new customers do
|
||||
* not start with a completely empty FAQ. They can edit or delete every
|
||||
* entry afterwards.
|
||||
* Seeds two example questions for a brand-new tenant, damit die FAQ-Seite
|
||||
* nicht voellig leer startet und sofort erkennbar ist, wie ein Eintrag
|
||||
* aussieht. Bewusst nur zwei Beispiele statt eines fertigen Katalogs: die
|
||||
* Antworten sind Platzhalter, die jeder Mandant ohnehin auf seine eigene
|
||||
* Teekueche umschreibt. Beide Eintraege sind frei editier- und loeschbar.
|
||||
*/
|
||||
function faq_seed_default_entries(PDO $pdo, int $tenantId): void
|
||||
{
|
||||
$starters = [
|
||||
['Wie starten wir mit unserer Kaffeeliste?', 'Mitglieder unter „Mitglieder verwalten" anlegen, danach direkt Striche und Einzahlungen erfassen. Es ist keine weitere Einrichtung nötig.'],
|
||||
['Wer kann Mitglieder verwalten oder Einstellungen ändern?', 'Das hängt von der zugewiesenen Rolle ab: Administrator und Kassenwart können Buchungen vornehmen, nur Administrator und Inhaber können Einstellungen und Zugänge verwalten.'],
|
||||
['Können wir per PayPal bezahlen?', 'Ja, wenn PayPal in den Mandant-Einstellungen aktiviert ist. Einzahlungen lassen sich zusätzlich jederzeit manuell erfassen.'],
|
||||
['Wie erfahre ich meinen aktuellen Stand?', 'Im eigenen Dashboard unter „Meine Kaffeeliste" ist der aktuelle Stand jederzeit sichtbar.'],
|
||||
['Wie starten wir mit unserer Kaffeeliste?', 'Beispielantwort – bitte anpassen: Mitglieder unter „Mitglieder verwalten" anlegen, danach direkt Striche und Einzahlungen erfassen.'],
|
||||
['Wie erfahre ich meinen aktuellen Stand?', 'Beispielantwort – bitte anpassen: Unter „Meine Kaffeeliste" ist der aktuelle Stand jederzeit sichtbar.'],
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO faq_entries (tenant_id, question, answer, sort_order) VALUES (?, ?, ?, ?)');
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Funktions-Schalter je Mandant, gepflegt vom Betreiber im Back-Office
|
||||
* (backoffice-mandant.php). Sie liegen eine Ebene ueber den
|
||||
* Mandant-Einstellungen: der Kunde stellt dort ein, *wie* eine Funktion
|
||||
* arbeitet - hier entscheidet der Betreiber, ob sie ihm ueberhaupt zur
|
||||
* Verfuegung steht.
|
||||
*
|
||||
* Bewusst kein Bestandteil von saas_user_has_role(): Rollen regeln, wer
|
||||
* innerhalb eines Mandanten etwas darf, Features regeln den Funktionsumfang
|
||||
* des Mandanten als Ganzes. Beide Pruefungen stehen deshalb nebeneinander.
|
||||
*
|
||||
* @return array<string, array{label: string, description: string}>
|
||||
*/
|
||||
function app_feature_catalog(): array
|
||||
{
|
||||
return [
|
||||
'faq' => [
|
||||
'label' => 'FAQ-Seite',
|
||||
'description' => 'Eigene Fragen und Antworten für die Mitglieder des Mandanten.',
|
||||
],
|
||||
'self_entry' => [
|
||||
'label' => 'Striche selbst eintragen',
|
||||
'description' => 'Mitglieder tragen ihre Striche im eigenen Dashboard ein (zusätzlich zur Mandant-Einstellung „Web-Striche erlauben").',
|
||||
],
|
||||
'pdf_export' => [
|
||||
'label' => 'Kaffeeliste als PDF',
|
||||
'description' => 'Ausdruck der Strichliste zum Aufhängen.',
|
||||
],
|
||||
'paypal_inbox' => [
|
||||
'label' => 'PayPal-Zahlungseingang',
|
||||
'description' => 'Automatisches Verbuchen weitergeleiteter PayPal-Mails samt Zuordnungs-Warteschlange.',
|
||||
],
|
||||
'csv_import' => [
|
||||
'label' => 'CSV-Import',
|
||||
'description' => 'Einlesen von Einzahlungen und Mitgliedern aus einer Datei.',
|
||||
],
|
||||
'mail_dispatch' => [
|
||||
'label' => 'Info-Mails und Erinnerungen',
|
||||
'description' => 'Sammelmails an Mitglieder und automatische Zahlungserinnerungen.',
|
||||
],
|
||||
'year_closing' => [
|
||||
'label' => 'Jahresabschluss',
|
||||
'description' => 'Verteilen eines Gesamtbetrags als Guthaben auf alle Mitglieder.',
|
||||
],
|
||||
'data_export' => [
|
||||
'label' => 'Datenexport',
|
||||
'description' => 'Herunterladen aller Mandantendaten durch den Kunden selbst.',
|
||||
],
|
||||
'branding' => [
|
||||
'label' => 'Eigenes Design',
|
||||
'description' => 'Eigenes Logo und eigene Akzentfarbe in der App-Oberfläche des Mandanten.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard fuer jede Funktion, wenn der Betreiber nichts abweichend gesetzt
|
||||
* hat. Alles ist freigeschaltet - ein neuer Kunde soll den vollen
|
||||
* Funktionsumfang bekommen, ohne dass der Betreiber erst etwas einschalten
|
||||
* muss.
|
||||
*/
|
||||
function app_feature_default(string $featureKey): bool
|
||||
{
|
||||
return array_key_exists($featureKey, app_feature_catalog());
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Stand aller bekannten Funktionen fuer einen Mandanten.
|
||||
*
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
function app_features_for_tenant(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$state = [];
|
||||
foreach (array_keys(app_feature_catalog()) as $key) {
|
||||
$state[$key] = app_feature_default($key);
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT feature_key, enabled FROM tenant_features WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$key = (string)$row['feature_key'];
|
||||
if (array_key_exists($key, $state)) {
|
||||
$state[$key] = (int)$row['enabled'] === 1;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Fehlt die Tabelle (Migration noch nicht eingespielt), bleibt es bei
|
||||
// den Standardwerten - eine nicht ausgerollte Migration darf keine
|
||||
// Funktion sperren.
|
||||
return $state;
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelne Funktion pruefen. Ergebnis wird pro Request zwischengespeichert,
|
||||
* damit Seiten mit mehreren Pruefungen (Navigation!) nicht je Aufruf erneut
|
||||
* die Datenbank befragen.
|
||||
*/
|
||||
function app_feature_enabled(PDO $pdo, int $tenantId, string $featureKey): bool
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
// Export und Rückgabe der eigenen Daten sind Bestandteil von Vertrag und
|
||||
// AVV und dürfen nicht versehentlich im Back-Office abgeschaltet werden.
|
||||
if ($featureKey === 'data_export') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
return app_feature_default($featureKey);
|
||||
}
|
||||
|
||||
if (!array_key_exists($tenantId, $cache)) {
|
||||
$cache[$tenantId] = app_features_for_tenant($pdo, $tenantId);
|
||||
}
|
||||
|
||||
return $cache[$tenantId][$featureKey] ?? app_feature_default($featureKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Schalter eines Mandanten neu. Erwartet die Formulardaten des
|
||||
* Back-Office-Formulars: jede angehakte Funktion steht als Schluessel in
|
||||
* $input['features'].
|
||||
*
|
||||
* @param array<string, mixed> $submittedFeatures
|
||||
* @return list<string> Liste der geaenderten Funktionsschluessel
|
||||
*/
|
||||
function app_features_update(PDO $pdo, int $tenantId, array $submittedFeatures): array
|
||||
{
|
||||
$before = app_features_for_tenant($pdo, $tenantId);
|
||||
$changed = [];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenant_features (tenant_id, feature_key, enabled)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)'
|
||||
);
|
||||
|
||||
foreach (array_keys(app_feature_catalog()) as $key) {
|
||||
$enabled = $key === 'data_export' || !empty($submittedFeatures[$key]);
|
||||
$stmt->execute([$tenantId, $key, $enabled ? 1 : 0]);
|
||||
if (($before[$key] ?? true) !== $enabled) {
|
||||
$changed[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitenwaechter: bricht mit einer verstaendlichen Meldung ab, wenn der
|
||||
* Betreiber die Funktion fuer diesen Mandanten abgeschaltet hat. Die Meldung
|
||||
* wird bewusst in die bereits geoeffnete Seite geschrieben, damit sie im
|
||||
* gewohnten Layout erscheint statt als nackter Text.
|
||||
*/
|
||||
function app_feature_notice_html(string $featureKey): string
|
||||
{
|
||||
$label = app_feature_catalog()[$featureKey]['label'] ?? $featureKey;
|
||||
|
||||
return '<div class="hint-box error"><p><b>' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8')
|
||||
. ' ist für euren Zugang nicht freigeschaltet.</b><br>'
|
||||
. 'Wende dich an den Betreiber der Kaffeeliste, wenn ihr diese Funktion nutzen möchtet.</p></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Zuordnung Betreiber-Funktion -> Schalter in den Mandant-Einstellungen.
|
||||
* Manche Funktionen kann der Kunde zusaetzlich selbst abschalten; wer PayPal
|
||||
* gar nicht als Zahlungsweg anbietet, braucht auch keine PayPal-Zuordnung.
|
||||
* Funktionen ohne Eintrag kennen nur den Betreiber-Schalter.
|
||||
*/
|
||||
function app_feature_tenant_switch(string $featureKey): ?string
|
||||
{
|
||||
$switches = [
|
||||
'paypal_inbox' => 'paypal_enabled',
|
||||
'self_entry' => 'self_entry_enabled',
|
||||
];
|
||||
|
||||
return $switches[$featureKey] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist eine Funktion fuer den Mandanten tatsaechlich nutzbar? Prueft beide
|
||||
* Ebenen: erst die Freischaltung durch den Betreiber, dann - falls vorhanden -
|
||||
* den eigenen Schalter des Kunden in den Mandant-Einstellungen.
|
||||
*/
|
||||
function app_feature_available(PDO $pdo, int $tenantId, string $featureKey): bool
|
||||
{
|
||||
if (!app_feature_enabled($pdo, $tenantId, $featureKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$switch = app_feature_tenant_switch($featureKey);
|
||||
if ($switch === null || $tenantId <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return app_tenant_switch_enabled($pdo, $tenantId, $switch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelnen Schalter aus den Mandant-Einstellungen lesen. Wie bei den
|
||||
* Betreiber-Funktionen wird das Ergebnis pro Request gehalten, damit die
|
||||
* Navigation die Einstellungen nicht mehrfach laedt.
|
||||
*/
|
||||
function app_tenant_switch_enabled(PDO $pdo, int $tenantId, string $settingKey): bool
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (!array_key_exists($tenantId, $cache)) {
|
||||
try {
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
$cache[$tenantId] = saas_fetch_tenant_settings($pdo, $tenantId) ?? [];
|
||||
} catch (Throwable $e) {
|
||||
// Lassen sich die Einstellungen nicht lesen, entscheidet allein die
|
||||
// Betreiber-Freischaltung - kein stiller Funktionsverlust.
|
||||
$cache[$tenantId] = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!array_key_exists($settingKey, $cache[$tenantId])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int)$cache[$tenantId][$settingKey] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gegenstueck zu app_feature_notice_html() fuer den Fall, dass nicht der
|
||||
* Betreiber, sondern der Mandant selbst die Funktion abgeschaltet hat. Hier
|
||||
* hilft kein Kontakt zum Betreiber, sondern der Weg in die eigenen
|
||||
* Einstellungen.
|
||||
*/
|
||||
function app_feature_tenant_notice_html(string $featureKey): string
|
||||
{
|
||||
$label = app_feature_catalog()[$featureKey]['label'] ?? $featureKey;
|
||||
|
||||
return '<div class="hint-box error"><p><b>' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8')
|
||||
. ' ist in euren Mandant-Einstellungen deaktiviert.</b><br>'
|
||||
. 'Ein Besitzer oder Administrator kann die Funktion unter '
|
||||
. '<a href="mandant-einstellungen.php">Mandant-Einstellungen</a> wieder einschalten.</p></div>';
|
||||
}
|
||||
+189
@@ -56,6 +56,99 @@ function imports_find_participant(PDO $pdo, int $tenantId, string $name): ?array
|
||||
];
|
||||
}
|
||||
|
||||
function imports_normalize_match_text(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
|
||||
return function_exists('mb_strtolower')
|
||||
? mb_strtolower($value, 'UTF-8')
|
||||
: strtolower($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert wahrscheinliche Teilnehmer fuer eine manuelle Zuordnung. Die
|
||||
* automatische Buchung bleibt strenger; diese Vorschlaege sind nur eine Hilfe
|
||||
* fuer Admins/Kassenwarte in CSV- und PayPal-Warteschlangen.
|
||||
*
|
||||
* @return list<array{participant_id:int, display_name:string, email:?string, paypal_name:?string, score:int}>
|
||||
*/
|
||||
function imports_suggest_participants(PDO $pdo, int $tenantId, string $rawName, ?string $note = null, int $limit = 5): array
|
||||
{
|
||||
$haystack = imports_normalize_match_text(trim($rawName . ' ' . (string)$note));
|
||||
if ($haystack === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = array_values(array_unique(array_filter(
|
||||
preg_split('/\s+/', $haystack) ?: [],
|
||||
static fn(string $token): bool => strlen($token) >= 3
|
||||
)));
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, display_name, email, paypal_name
|
||||
FROM participants
|
||||
WHERE tenant_id = ? AND active = 1
|
||||
ORDER BY display_name'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
$candidates = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$displayName = (string)$row['display_name'];
|
||||
$paypalName = (string)($row['paypal_name'] ?? '');
|
||||
$email = (string)($row['email'] ?? '');
|
||||
$displayNorm = imports_normalize_match_text($displayName);
|
||||
$paypalNorm = imports_normalize_match_text($paypalName);
|
||||
$emailNorm = imports_normalize_match_text($email);
|
||||
$score = 0;
|
||||
|
||||
if ($displayNorm !== '' && $displayNorm === imports_normalize_match_text($rawName)) {
|
||||
$score += 90;
|
||||
}
|
||||
if ($paypalNorm !== '' && $paypalNorm === imports_normalize_match_text($rawName)) {
|
||||
$score += 100;
|
||||
}
|
||||
if ($displayNorm !== '' && str_contains($haystack, $displayNorm)) {
|
||||
$score += 45;
|
||||
}
|
||||
if ($paypalNorm !== '' && str_contains($haystack, $paypalNorm)) {
|
||||
$score += 55;
|
||||
}
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if ($displayNorm !== '' && str_contains($displayNorm, $token)) {
|
||||
$score += 12;
|
||||
}
|
||||
if ($paypalNorm !== '' && str_contains($paypalNorm, $token)) {
|
||||
$score += 16;
|
||||
}
|
||||
if ($emailNorm !== '' && str_contains($emailNorm, $token)) {
|
||||
$score += 6;
|
||||
}
|
||||
}
|
||||
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = [
|
||||
'participant_id' => (int)$row['id'],
|
||||
'display_name' => $displayName,
|
||||
'email' => $row['email'] !== null ? (string)$row['email'] : null,
|
||||
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
usort($candidates, static function (array $a, array $b): int {
|
||||
return $b['score'] <=> $a['score']
|
||||
?: strcasecmp((string)$a['display_name'], (string)$b['display_name']);
|
||||
});
|
||||
|
||||
return array_slice($candidates, 0, max(1, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* A payment counts as a duplicate if the same participant already has a
|
||||
* non-voided ledger payment of the same amount on the same day.
|
||||
@@ -188,6 +281,102 @@ function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int, original_filename:string, status:string, created_at:string, committed_at:?string, total_rows:int, matched_rows:int, unresolved_rows:int, imported_rows:int, ignored_rows:int}>
|
||||
*/
|
||||
function imports_fetch_recent_batches(PDO $pdo, int $tenantId, int $limit = 10): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT b.id, b.original_filename, b.status, b.created_at, b.committed_at,
|
||||
COUNT(r.id) AS total_rows,
|
||||
SUM(CASE WHEN r.status = 'matched' THEN 1 ELSE 0 END) AS matched_rows,
|
||||
SUM(CASE WHEN r.status IN ('unmatched', 'duplicate', 'invalid') THEN 1 ELSE 0 END) AS unresolved_rows,
|
||||
SUM(CASE WHEN r.status = 'imported' THEN 1 ELSE 0 END) AS imported_rows,
|
||||
SUM(CASE WHEN r.status = 'ignored' THEN 1 ELSE 0 END) AS ignored_rows
|
||||
FROM payment_import_batches b
|
||||
LEFT JOIN payment_import_rows r ON r.batch_id = b.id
|
||||
WHERE b.tenant_id = ?
|
||||
GROUP BY b.id, b.original_filename, b.status, b.created_at, b.committed_at
|
||||
ORDER BY b.created_at DESC, b.id DESC
|
||||
LIMIT " . max(1, min(50, $limit))
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return array_map(static function (array $row): array {
|
||||
return [
|
||||
'id' => (int)$row['id'],
|
||||
'original_filename' => (string)$row['original_filename'],
|
||||
'status' => (string)$row['status'],
|
||||
'created_at' => (string)$row['created_at'],
|
||||
'committed_at' => $row['committed_at'] !== null ? (string)$row['committed_at'] : null,
|
||||
'total_rows' => (int)$row['total_rows'],
|
||||
'matched_rows' => (int)$row['matched_rows'],
|
||||
'unresolved_rows' => (int)$row['unresolved_rows'],
|
||||
'imported_rows' => (int)$row['imported_rows'],
|
||||
'ignored_rows' => (int)$row['ignored_rows'],
|
||||
];
|
||||
}, $stmt->fetchAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
function imports_assign_row(PDO $pdo, int $tenantId, int $batchId, int $rowId, int $participantId): array
|
||||
{
|
||||
$batch = imports_fetch_batch($pdo, $tenantId, $batchId);
|
||||
if ($batch === null || $batch['batch']['status'] !== 'previewed') {
|
||||
return ['ok' => false, 'error' => 'Dieser Import kann nicht mehr bearbeitet werden.'];
|
||||
}
|
||||
|
||||
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]);
|
||||
$participant = $summaries[0] ?? null;
|
||||
if ($participant === null || empty($participant['active'])) {
|
||||
return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
SET r.participant_id = ?, r.status = 'matched'
|
||||
WHERE r.id = ?
|
||||
AND r.batch_id = ?
|
||||
AND b.tenant_id = ?
|
||||
AND b.status = 'previewed'
|
||||
AND r.status IN ('unmatched', 'duplicate')"
|
||||
);
|
||||
$stmt->execute([$participantId, $rowId, $batchId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
return ['ok' => false, 'error' => 'Diese Zeile kann nicht zugeordnet werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
function imports_ignore_row(PDO $pdo, int $tenantId, int $batchId, int $rowId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
SET r.participant_id = NULL, r.status = 'ignored'
|
||||
WHERE r.id = ?
|
||||
AND r.batch_id = ?
|
||||
AND b.tenant_id = ?
|
||||
AND b.status = 'previewed'
|
||||
AND r.status IN ('unmatched', 'duplicate', 'invalid')"
|
||||
);
|
||||
$stmt->execute([$rowId, $batchId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
return ['ok' => false, 'error' => 'Diese Zeile kann nicht ignoriert werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits every 'matched' row of a previewed batch straight into the ledger.
|
||||
* Already-committed batches are rejected.
|
||||
|
||||
@@ -523,6 +523,21 @@ function ledger_record_payment(PDO $pdo, int $tenantId, int $participantId, int
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bis zu dieser Mitgliederzahl passt die Strichliste auf ein einzelnes Blatt.
|
||||
* Erst darueber wird sie auf Vorder- und Rueckseite aufgeteilt - und nur dann
|
||||
* ergeben auch die Vorder-/Rueckseiten-Ansichten beim Erfassen einen Sinn.
|
||||
*/
|
||||
const LEDGER_SINGLE_PAGE_MAX_MEMBERS = 50;
|
||||
|
||||
/**
|
||||
* Braucht dieser Mandant eine zweiseitige Liste (Vorder- und Rueckseite)?
|
||||
*/
|
||||
function ledger_uses_two_sided_sheet(int $activeMemberCount): bool
|
||||
{
|
||||
return $activeMemberCount > LEDGER_SINGLE_PAGE_MAX_MEMBERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* "100-Tage-Liste" Vorder-/Rueckseiten-Aufteilung: aktive Teilnehmer, deren
|
||||
* summierte Striche im nachlaufenden Fenster die Zehner-Schwelle erreichen
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/legal.php';
|
||||
require_once __DIR__ . '/saas-mail.php';
|
||||
require_once __DIR__ . '/rate-limit.php';
|
||||
require_once __DIR__ . '/billing.php';
|
||||
|
||||
/** @return list<string> */
|
||||
function app_validate_legal_request(array $input, string $requestType): array
|
||||
{
|
||||
$errors = [];
|
||||
$name = trim((string)($input['requester_name'] ?? ''));
|
||||
$email = trim((string)($input['requester_email'] ?? ''));
|
||||
$reference = trim((string)($input['contract_reference'] ?? ''));
|
||||
if (strlen($name) < 2 || strlen($name) > 255) {
|
||||
$errors[] = 'Bitte gib deinen vollständigen Namen an.';
|
||||
}
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Bitte gib eine gültige E-Mail-Adresse für die Bestätigung an.';
|
||||
}
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9-]{1,98}[a-z0-9]$/', strtolower($reference))) {
|
||||
$errors[] = 'Bitte gib das Kundenkürzel aus deinem Konto an.';
|
||||
}
|
||||
|
||||
if ($requestType === 'cancellation') {
|
||||
$kind = (string)($input['cancellation_kind'] ?? '');
|
||||
if (!in_array($kind, ['ordinary', 'extraordinary'], true)) {
|
||||
$errors[] = 'Bitte wähle die Art der Kündigung.';
|
||||
}
|
||||
if ($kind === 'extraordinary' && trim((string)($input['request_reason'] ?? '')) === '') {
|
||||
$errors[] = 'Bitte nenne bei einer außerordentlichen Kündigung den Kündigungsgrund.';
|
||||
}
|
||||
$requestedEnd = (string)($input['requested_end'] ?? '');
|
||||
if ($requestedEnd !== 'earliest') {
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $requestedEnd);
|
||||
if ($date === false || $date->format('Y-m-d') !== $requestedEnd) {
|
||||
$errors[] = 'Bitte wähle „frühestmöglich“ oder ein gültiges Beendigungsdatum.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (strlen((string)($input['request_reason'] ?? '')) > 5000) {
|
||||
$errors[] = 'Der Grund ist zu lang (höchstens 5.000 Zeichen).';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
function app_update_legal_request(PDO $pdo, string $referenceCode, string $status, array $metadata = []): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE legal_requests SET status = ?, metadata_json = ?, processed_at = NOW() WHERE reference_code = ?'
|
||||
);
|
||||
$stmt->execute([
|
||||
$status,
|
||||
$metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
$referenceCode,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordentliche Kündigungen eines eindeutig zugeordneten Stripe-Abos werden
|
||||
* sofort zum Periodenende vorgemerkt. Alle anderen Erklärungen bleiben als
|
||||
* fristwahrender Eingang in der DB und werden manuell bearbeitet.
|
||||
*
|
||||
* @return array{status: string, effective_end: string, processing_note: string}
|
||||
*/
|
||||
function app_process_cancellation_request(PDO $pdo, array $stored, array $input): array
|
||||
{
|
||||
$effectiveEnd = (string)$input['requested_end'] === 'earliest'
|
||||
? 'zum frühestmöglichen Zeitpunkt'
|
||||
: (string)$input['requested_end'];
|
||||
$manual = [
|
||||
'status' => 'received_manual',
|
||||
'effective_end' => $effectiveEnd,
|
||||
'processing_note' => 'Die Erklärung ist eingegangen und wird anhand der Vertragsdaten bearbeitet.',
|
||||
];
|
||||
|
||||
if ($stored['tenant_id'] === null
|
||||
|| (string)$input['cancellation_kind'] !== 'ordinary'
|
||||
|| (string)$input['requested_end'] !== 'earliest'
|
||||
) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
|
||||
$billing = billing_fetch_or_init($pdo, (int)$stored['tenant_id']);
|
||||
if (empty($billing['stripe_subscription_id'])) {
|
||||
if ((string)$billing['plan_code'] === 'free') {
|
||||
$pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = NOW() WHERE id = ?')
|
||||
->execute([(int)$stored['tenant_id']]);
|
||||
$effectiveEnd = date('d.m.Y, H:i') . ' Uhr';
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'contract_ended', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
'effective_end' => $effectiveEnd,
|
||||
]);
|
||||
return [
|
||||
'status' => 'contract_ended',
|
||||
'effective_end' => $effectiveEnd,
|
||||
'processing_note' => 'Der kostenlose Vertrag ist beendet. Die Anmeldung ist nicht mehr möglich; operative Mandantendaten werden nach 30 Tagen gelöscht.',
|
||||
];
|
||||
}
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = stripe_schedule_subscription_cancellation((string)$billing['stripe_subscription_id']);
|
||||
} catch (Throwable $e) {
|
||||
$result = ['ok' => false, 'current_period_end' => null, 'error' => $e->getMessage()];
|
||||
}
|
||||
if (!$result['ok']) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
'stripe_error' => (string)$result['error'],
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
|
||||
billing_update($pdo, (int)$stored['tenant_id'], [
|
||||
'subscription_status' => 'canceling',
|
||||
'current_period_end' => $result['current_period_end'],
|
||||
]);
|
||||
if ($result['current_period_end'] !== null) {
|
||||
$pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = ? WHERE id = ?')
|
||||
->execute([$result['current_period_end'], (int)$stored['tenant_id']]);
|
||||
}
|
||||
$effectiveEnd = $result['current_period_end'] !== null
|
||||
? date('d.m.Y, H:i', strtotime((string)$result['current_period_end'])) . ' Uhr'
|
||||
: 'zum Ende der laufenden Abrechnungsperiode';
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'cancellation_scheduled', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
'effective_end' => $effectiveEnd,
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'cancellation_scheduled',
|
||||
'effective_end' => $effectiveEnd,
|
||||
'processing_note' => 'Das kostenpflichtige Abonnement wurde bei Stripe zum Ende der laufenden Abrechnungsperiode vorgemerkt.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, effective_end: string, processing_note: string}
|
||||
*/
|
||||
function app_process_withdrawal_request(PDO $pdo, array $stored): array
|
||||
{
|
||||
$manual = [
|
||||
'status' => 'received_manual',
|
||||
'effective_end' => 'mit Eingang des Widerrufs, sofern das Widerrufsrecht besteht',
|
||||
'processing_note' => 'Der Widerruf ist eingegangen und wird unverzüglich anhand der Vertragsdaten geprüft.',
|
||||
];
|
||||
if ($stored['tenant_id'] === null) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual');
|
||||
return $manual;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT customer_type, created_at FROM tenants WHERE id = ?');
|
||||
$stmt->execute([(int)$stored['tenant_id']]);
|
||||
$tenant = $stmt->fetch();
|
||||
if ($tenant === false) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual');
|
||||
return $manual;
|
||||
}
|
||||
|
||||
$billing = billing_fetch_or_init($pdo, (int)$stored['tenant_id']);
|
||||
if ((string)$billing['plan_code'] === 'free') {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT accepted_at, metadata_json FROM legal_acceptances
|
||||
WHERE tenant_id = ? AND document_type = 'terms' AND context = 'registration'
|
||||
ORDER BY accepted_at ASC LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$stored['tenant_id']]);
|
||||
$contractEvidence = $stmt->fetch();
|
||||
$contractStart = $contractEvidence !== false
|
||||
? strtotime((string)$contractEvidence['accepted_at'])
|
||||
: strtotime((string)$tenant['created_at']);
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT accepted_at, metadata_json FROM legal_acceptances
|
||||
WHERE tenant_id = ? AND document_type = 'withdrawal_information' AND context = 'paid_order'
|
||||
ORDER BY accepted_at DESC LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$stored['tenant_id']]);
|
||||
$contractEvidence = $stmt->fetch();
|
||||
$contractStart = $contractEvidence !== false ? strtotime((string)$contractEvidence['accepted_at']) : false;
|
||||
}
|
||||
$contractMetadata = $contractEvidence !== false
|
||||
? json_decode((string)($contractEvidence['metadata_json'] ?? ''), true)
|
||||
: null;
|
||||
$contractCustomerType = is_array($contractMetadata)
|
||||
? (string)($contractMetadata['customer_type'] ?? $tenant['customer_type'])
|
||||
: (string)$tenant['customer_type'];
|
||||
if ($contractCustomerType !== 'consumer') {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'review_reason' => 'contract_not_recorded_as_consumer',
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
if ($contractStart === false || $contractStart < strtotime('-14 days')) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'review_reason' => 'automatic_14_day_window_not_proven',
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
|
||||
$stripeError = null;
|
||||
if (!empty($billing['stripe_subscription_id'])) {
|
||||
try {
|
||||
$cancel = stripe_cancel_subscription((string)$billing['stripe_subscription_id']);
|
||||
if (!$cancel['ok']) {
|
||||
$stripeError = (string)$cancel['error'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$stripeError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = NOW() WHERE id = ?')
|
||||
->execute([(int)$stored['tenant_id']]);
|
||||
if ($stripeError === null && !empty($billing['stripe_subscription_id'])) {
|
||||
billing_update($pdo, (int)$stored['tenant_id'], [
|
||||
'plan_code' => 'free',
|
||||
'subscription_status' => 'canceled',
|
||||
]);
|
||||
}
|
||||
|
||||
$status = $stripeError === null ? 'withdrawal_effective' : 'withdrawal_action_required';
|
||||
$effectiveEnd = date('d.m.Y, H:i') . ' Uhr';
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], $status, [
|
||||
'effective_end' => $effectiveEnd,
|
||||
'stripe_error' => $stripeError,
|
||||
'refund_review_required' => (string)$billing['plan_code'] !== 'free',
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'effective_end' => $effectiveEnd,
|
||||
'processing_note' => $stripeError === null
|
||||
? 'Der Vertrag wurde beendet. Eine gegebenenfalls geschuldete Rückzahlung wird unverzüglich über das ursprüngliche Zahlungsmittel bearbeitet.'
|
||||
: 'Der Vertrag wurde lokal beendet. Die Beendigung beim Zahlungsdienst und eine gegebenenfalls geschuldete Rückzahlung müssen noch manuell abgeschlossen werden.',
|
||||
];
|
||||
}
|
||||
|
||||
function app_legal_request_confirmation_text(array $input, array $stored, array $processing): string
|
||||
{
|
||||
$isCancellation = (string)$input['request_type'] === 'cancellation';
|
||||
$type = $isCancellation ? 'Kündigung' : 'Widerruf';
|
||||
$lines = [
|
||||
"Eingangsbestätigung {$type} – Kaffeeliste",
|
||||
'',
|
||||
'Referenz: ' . $stored['reference_code'],
|
||||
'Eingang: ' . $stored['received_at'] . ' (Europe/Berlin)',
|
||||
'Name: ' . $input['requester_name'],
|
||||
'Bestätigungs-E-Mail: ' . $input['requester_email'],
|
||||
'Kundenkürzel / Vertragsreferenz: ' . $input['contract_reference'],
|
||||
];
|
||||
if ($isCancellation) {
|
||||
$lines[] = 'Art: ' . ((string)$input['cancellation_kind'] === 'extraordinary' ? 'außerordentliche Kündigung' : 'ordentliche Kündigung');
|
||||
$lines[] = 'Beendigung: ' . $processing['effective_end'];
|
||||
}
|
||||
if (trim((string)($input['request_reason'] ?? '')) !== '') {
|
||||
$lines[] = 'Erklärung / Grund: ' . trim((string)$input['request_reason']);
|
||||
}
|
||||
$lines[] = '';
|
||||
$lines[] = $processing['processing_note'];
|
||||
$lines[] = '';
|
||||
$lines[] = 'Clemens Creutzburg, CTB-IT';
|
||||
$lines[] = 'In den Sieben Stücken 9d, 30655 Hannover';
|
||||
$lines[] = app_legal_email();
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
function app_send_legal_request_confirmations(array $input, array $stored, array $processing): array
|
||||
{
|
||||
$text = app_legal_request_confirmation_text($input, $stored, $processing);
|
||||
$downloadUrl = saas_app_url('rechtserklaerung-bestaetigung.php?token=' . urlencode((string)$stored['access_token']) . '&download=1');
|
||||
$type = (string)$input['request_type'] === 'cancellation' ? 'Kündigung' : 'Widerruf';
|
||||
$customerResult = saas_send_mail(
|
||||
(string)$input['requester_email'],
|
||||
"Kaffeeliste: Eingang {$type} " . $stored['reference_code'],
|
||||
$text . "\n\nBestätigung als Textdatei speichern:\n" . $downloadUrl
|
||||
);
|
||||
$operatorResult = saas_send_mail(
|
||||
app_legal_email(),
|
||||
"Kaffeeliste: {$type} eingegangen " . $stored['reference_code'],
|
||||
$text . "\n\nInterner Status: " . $processing['status']
|
||||
);
|
||||
|
||||
return [
|
||||
'customer_ok' => (bool)($customerResult['ok'] ?? false),
|
||||
'operator_ok' => (bool)($operatorResult['ok'] ?? false),
|
||||
];
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
const APP_TERMS_VERSION = '2026-08-22';
|
||||
const APP_PRIVACY_VERSION = '2026-08-27-2';
|
||||
const APP_DPA_VERSION = '2026-08-22';
|
||||
const APP_WITHDRAWAL_VERSION = '2026-08-22';
|
||||
|
||||
function app_legal_email(): string
|
||||
{
|
||||
return (string)app_env('LEGAL_EMAIL', 'info@ctb-it.de');
|
||||
}
|
||||
|
||||
function app_legal_phone(): string
|
||||
{
|
||||
return trim((string)app_env('LEGAL_PHONE', ''));
|
||||
}
|
||||
|
||||
function app_legal_ticket_url(): string
|
||||
{
|
||||
return rtrim((string)app_env('LEGAL_TICKET_URL', 'https://ticketsystem.ctb-it.de'), '/');
|
||||
}
|
||||
|
||||
function app_public_legal_footer(): string
|
||||
{
|
||||
return '<footer class="public-footer-links">'
|
||||
. '<a href="impressum.php">Impressum</a>'
|
||||
. '<a href="agb.php">AGB</a>'
|
||||
. '<a href="datenschutz.php">Datenschutz</a>'
|
||||
. '<a href="avv.php">AVV</a>'
|
||||
. '<a href="widerruf.php">Widerrufsbelehrung</a>'
|
||||
. '<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>'
|
||||
. '<a href="preise.php">Preise</a>'
|
||||
. '<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>'
|
||||
. app_analytics_footer_settings_button_html()
|
||||
. '</footer>';
|
||||
}
|
||||
|
||||
function app_legal_document_version(string $documentType): string
|
||||
{
|
||||
return match ($documentType) {
|
||||
'terms' => APP_TERMS_VERSION,
|
||||
'privacy_notice' => APP_PRIVACY_VERSION,
|
||||
'dpa' => APP_DPA_VERSION,
|
||||
'withdrawal_information', 'early_performance' => APP_WITHDRAWAL_VERSION,
|
||||
default => throw new InvalidArgumentException('Unbekannter Rechtstext.'),
|
||||
};
|
||||
}
|
||||
|
||||
function app_legal_document_text(string $documentType): string
|
||||
{
|
||||
$filename = match ($documentType) {
|
||||
'terms' => 'agb-' . APP_TERMS_VERSION . '.txt',
|
||||
'privacy_notice' => 'datenschutz-' . APP_PRIVACY_VERSION . '.txt',
|
||||
'dpa' => 'avv-' . APP_DPA_VERSION . '.txt',
|
||||
'withdrawal_information', 'early_performance' => 'widerruf-' . APP_WITHDRAWAL_VERSION . '.txt',
|
||||
default => throw new InvalidArgumentException('Unbekannter Rechtstext.'),
|
||||
};
|
||||
$path = APP_ROOT . '/legal/' . $filename;
|
||||
$content = is_file($path) ? file_get_contents($path) : false;
|
||||
if ($content === false) {
|
||||
throw new RuntimeException('Rechtstext fehlt: ' . $filename);
|
||||
}
|
||||
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
function app_render_legal_document(string $documentType): string
|
||||
{
|
||||
$blocks = preg_split('/\R\s*\R/', app_legal_document_text($documentType)) ?: [];
|
||||
$html = '';
|
||||
foreach ($blocks as $block) {
|
||||
$block = trim($block);
|
||||
if ($block === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($block, '## ')) {
|
||||
$html .= '<h2>' . htmlspecialchars(substr($block, 3), ENT_QUOTES, 'UTF-8') . '</h2>';
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($block, '### ')) {
|
||||
$html .= '<h3>' . htmlspecialchars(substr($block, 4), ENT_QUOTES, 'UTF-8') . '</h3>';
|
||||
continue;
|
||||
}
|
||||
$lines = preg_split('/\R/', $block) ?: [];
|
||||
if ($lines !== [] && count(array_filter($lines, static fn(string $line): bool => str_starts_with(trim($line), '- '))) === count($lines)) {
|
||||
$html .= '<ul>';
|
||||
foreach ($lines as $line) {
|
||||
$html .= '<li>' . htmlspecialchars(substr(trim($line), 2), ENT_QUOTES, 'UTF-8') . '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
continue;
|
||||
}
|
||||
$escaped = htmlspecialchars($block, ENT_QUOTES, 'UTF-8');
|
||||
$escaped = preg_replace_callback(
|
||||
'~\[(.+?)\]\((https?://[^)]+|[a-z0-9-]+\.php)\)~i',
|
||||
static fn(array $match): string => '<a href="' . htmlspecialchars($match[2], ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($match[1], ENT_QUOTES, 'UTF-8') . '</a>',
|
||||
$escaped
|
||||
) ?? $escaped;
|
||||
$html .= '<p>' . nl2br($escaped) . '</p>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
function app_record_legal_acceptance(
|
||||
PDO $pdo,
|
||||
?int $tenantId,
|
||||
?int $userId,
|
||||
string $documentType,
|
||||
string $context,
|
||||
array $metadata = []
|
||||
): void {
|
||||
$evidence = [
|
||||
'document_sha256' => hash('sha256', app_legal_document_text($documentType)),
|
||||
];
|
||||
if ($tenantId !== null) {
|
||||
$snapshotStmt = $pdo->prepare('SELECT slug, name FROM tenants WHERE id = ?');
|
||||
$snapshotStmt->execute([$tenantId]);
|
||||
$tenantSnapshot = $snapshotStmt->fetch();
|
||||
if ($tenantSnapshot !== false) {
|
||||
$evidence['tenant_slug'] = (string)$tenantSnapshot['slug'];
|
||||
$evidence['tenant_name'] = (string)$tenantSnapshot['name'];
|
||||
}
|
||||
}
|
||||
if ($userId !== null) {
|
||||
$snapshotStmt = $pdo->prepare('SELECT email_norm FROM users WHERE id = ?');
|
||||
$snapshotStmt->execute([$userId]);
|
||||
$userEmail = $snapshotStmt->fetchColumn();
|
||||
if ($userEmail !== false) {
|
||||
$evidence['user_email'] = (string)$userEmail;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO legal_acceptances
|
||||
(tenant_id, user_id, document_type, document_version, context, ip, user_agent, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
$userId,
|
||||
$documentType,
|
||||
app_legal_document_version($documentType),
|
||||
$context,
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
|
||||
json_encode(
|
||||
$evidence + $metadata,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet einen Vertrag nur dann automatisch, wenn Kundenkürzel und die
|
||||
* E-Mail eines aktiven Inhabers zusammenpassen. Die Erklärung wird auch bei
|
||||
* fehlender Zuordnung gespeichert: Eine fehlerhafte Referenz darf nicht zum
|
||||
* Verlust einer fristgebundenen Kündigung oder eines Widerrufs führen.
|
||||
*
|
||||
* @return array{tenant_id: ?int, user_id: ?int, customer_type: ?string}
|
||||
*/
|
||||
function app_match_legal_contract(PDO $pdo, string $contractReference, string $email): array
|
||||
{
|
||||
$slug = strtolower(trim($contractReference));
|
||||
$emailNorm = strtolower(trim($email));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id AS tenant_id, t.customer_type, u.id AS user_id
|
||||
FROM tenants t
|
||||
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
|
||||
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
|
||||
WHERE t.slug = ? AND u.email_norm = ?
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$slug, $emailNorm]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row === false) {
|
||||
return ['tenant_id' => null, 'user_id' => null, 'customer_type' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'tenant_id' => (int)$row['tenant_id'],
|
||||
'user_id' => (int)$row['user_id'],
|
||||
'customer_type' => (string)$row['customer_type'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{reference_code: string, access_token: string, received_at: string, tenant_id: ?int, user_id: ?int}
|
||||
*/
|
||||
function app_store_legal_request(PDO $pdo, array $input): array
|
||||
{
|
||||
$match = app_match_legal_contract(
|
||||
$pdo,
|
||||
(string)$input['contract_reference'],
|
||||
(string)$input['requester_email']
|
||||
);
|
||||
$accessToken = bin2hex(random_bytes(32));
|
||||
$referenceCode = 'KL-' . strtoupper(bin2hex(random_bytes(6)));
|
||||
$receivedAt = date('Y-m-d H:i:s');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO legal_requests
|
||||
(reference_code, access_token_hash, request_type, tenant_id, user_id,
|
||||
requester_name, requester_email, contract_reference, request_reason,
|
||||
requested_end, status, ip, user_agent, metadata_json, received_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$referenceCode,
|
||||
hash('sha256', $accessToken),
|
||||
(string)$input['request_type'],
|
||||
$match['tenant_id'],
|
||||
$match['user_id'],
|
||||
(string)$input['requester_name'],
|
||||
(string)$input['requester_email'],
|
||||
(string)$input['contract_reference'],
|
||||
($input['request_reason'] ?? '') !== '' ? (string)$input['request_reason'] : null,
|
||||
($input['requested_end'] ?? '') !== '' ? (string)$input['requested_end'] : null,
|
||||
'received',
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
|
||||
isset($input['metadata']) ? json_encode($input['metadata'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
$receivedAt,
|
||||
]);
|
||||
|
||||
return [
|
||||
'reference_code' => $referenceCode,
|
||||
'access_token' => $accessToken,
|
||||
'received_at' => $receivedAt,
|
||||
'tenant_id' => $match['tenant_id'],
|
||||
'user_id' => $match['user_id'],
|
||||
];
|
||||
}
|
||||
|
||||
function app_fetch_legal_request_by_token(PDO $pdo, string $token): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT reference_code, request_type, requester_name, requester_email,
|
||||
contract_reference, request_reason, requested_end, status, metadata_json, received_at
|
||||
FROM legal_requests WHERE access_token_hash = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([hash('sha256', $token)]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row !== false ? $row : null;
|
||||
}
|
||||
+302
-17
@@ -124,14 +124,23 @@ function paypal_booking_note(array $payment): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht den passenden Teilnehmer zu einer PayPal-Zahlung: zuerst ueber den
|
||||
* Zahlernamen (paypal_name/display_name), sonst ueber die Mitteilung (Mitglied
|
||||
* schreibt dort teils seinen Namen). Liefert null, wenn nicht eindeutig.
|
||||
* Sucht den passenden Teilnehmer zu einer PayPal-Zahlung: zuerst ueber die
|
||||
* Mailadresse des Zahlers, dann ueber den Zahlernamen
|
||||
* (paypal_name/display_name), zuletzt ueber die Mitteilung (Mitglied schreibt
|
||||
* dort teils seinen Namen). Liefert null, wenn nicht eindeutig.
|
||||
*
|
||||
* @return array{participant_id:int, display_name:string}|null
|
||||
*/
|
||||
function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?string $note): ?array
|
||||
function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?string $note, ?string $payerEmail = null): ?array
|
||||
{
|
||||
// Die Mailadresse zuerst: sie ist im Mandanten eindeutig (Unique Key auf
|
||||
// tenant_id + email_norm) und aendert sich nicht, wenn jemand bei PayPal
|
||||
// unter einem anderen Namen auftritt.
|
||||
$byEmail = paypal_find_participant_by_email($pdo, $tenantId, $payerEmail);
|
||||
if ($byEmail !== null) {
|
||||
return $byEmail;
|
||||
}
|
||||
|
||||
$byName = imports_find_participant($pdo, $tenantId, $payerName);
|
||||
if ($byName !== null) {
|
||||
return $byName;
|
||||
@@ -143,11 +152,96 @@ function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?s
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sieht der Absender nach PayPal aus? Erkennt Landesvarianten (paypal.at,
|
||||
* paypal.co.uk) und Versand-Subdomains (e.paypal.de); der Vergleich haengt
|
||||
* am Ende der Domain, "paypal.de.beispiel.com" ist also kein PayPal.
|
||||
*
|
||||
* Nur zur Anzeige in der Diagnose (check-imap-support.php): die
|
||||
* Verarbeitung prueft den Absender bewusst nicht, weil Zahlungsmails auch
|
||||
* ueber manuelle Weiterleitungen ankommen. Massgeblich ist die
|
||||
* Empfangsadresse.
|
||||
*/
|
||||
function paypal_from_is_paypal(string $fromHeader): bool
|
||||
{
|
||||
if (!preg_match_all('/[\w.+-]+@([\w-]+(?:\.[\w-]+)+)/u', strtolower($fromHeader), $treffer)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($treffer[1] as $domain) {
|
||||
if (preg_match('/(^|\.)paypal\.[a-z]{2,}(\.[a-z]{2,})?$/', $domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gehoeren zwei Adressen zum selben Postfach? Plus-Adressierung wird dabei
|
||||
* ignoriert, "zahlungen+ab12cd@host" ist also dasselbe Postfach wie
|
||||
* "zahlungen@host". $kandidat darf mehrere Adressen enthalten (die
|
||||
* Empfaengerzeilen einer weitergeleiteten Mail).
|
||||
*/
|
||||
function paypal_same_mailbox(string $adresse, string $kandidat): bool
|
||||
{
|
||||
$adresse = strtolower(trim($adresse));
|
||||
if ($adresse === '' || !str_contains($adresse, '@')) {
|
||||
return false;
|
||||
}
|
||||
[$lokal, $domain] = explode('@', $adresse, 2);
|
||||
$lokal = explode('+', $lokal)[0];
|
||||
|
||||
if (!preg_match_all('/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/u', strtolower($kandidat), $treffer)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($treffer[0] as $andere) {
|
||||
[$andereLokal, $andereDomain] = explode('@', $andere, 2);
|
||||
if (explode('+', $andereLokal)[0] === $lokal && $andereDomain === $domain) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitglied ueber seine Mailadresse finden. Null, wenn keine Adresse vorliegt
|
||||
* oder kein Mitglied dazu passt.
|
||||
*/
|
||||
function paypal_find_participant_by_email(PDO $pdo, int $tenantId, ?string $email): ?array
|
||||
{
|
||||
$email = strtolower(trim((string) $email));
|
||||
if ($email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, display_name
|
||||
FROM participants
|
||||
WHERE tenant_id = ? AND email_norm = ?
|
||||
ORDER BY id
|
||||
LIMIT 2'
|
||||
);
|
||||
$stmt->execute([$tenantId, $email]);
|
||||
$rows = $stmt->fetchAll();
|
||||
if (count($rows) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'participant_id' => (int) $rows[0]['id'],
|
||||
'display_name' => (string) $rows[0]['display_name'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine geparste PayPal-Zahlung fuer einen Mandanten:
|
||||
* - Dedup ueber den Transaktionscode (bereits verarbeitet -> uebersprungen).
|
||||
* - Eindeutiger Match -> automatisch als Netto-Einzahlung gebucht.
|
||||
* - Kein eindeutiger Match -> in der Warteschlange ('unmatched') abgelegt.
|
||||
* - PayPal fuer den Mandanten abgeschaltet -> geparkt ('parked'): gespeichert,
|
||||
* aber ohne automatische Buchung.
|
||||
*
|
||||
* @param array $parsed Ergebnis von paypal_parse_notification()
|
||||
* @return array{status:string, payment_id?:int, participant?:string}
|
||||
@@ -166,13 +260,14 @@ function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUse
|
||||
// Dedup: Transaktionscode ist global eindeutig. INSERT IGNORE als Schranke.
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT IGNORE INTO paypal_payments
|
||||
(tenant_id, transaction_code, payer_name, note, gross_cents, fee_cents, net_cents, paid_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
(tenant_id, transaction_code, payer_name, payer_email, note, gross_cents, fee_cents, net_cents, paid_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$insert->execute([
|
||||
$tenantId,
|
||||
$code,
|
||||
(string) ($parsed['payer_name'] ?? ''),
|
||||
(string) ($parsed['payer_email'] ?? ''),
|
||||
$parsed['note'] ?? null,
|
||||
(int) ($parsed['gross_cents'] ?? 0),
|
||||
$parsed['fee_cents'] ?? null,
|
||||
@@ -185,8 +280,25 @@ function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUse
|
||||
}
|
||||
$paymentId = (int) $pdo->lastInsertId();
|
||||
|
||||
// Ist PayPal fuer diesen Mandanten abgeschaltet - vom Betreiber oder vom
|
||||
// Mandanten selbst -, wird die Zahlung nur geparkt: gespeichert, aber
|
||||
// nicht automatisch gutgeschrieben. Wegwerfen waere schlechter (eine
|
||||
// echte Zahlung ginge unbemerkt verloren), automatisch buchen ebenfalls
|
||||
// (es soll ja gerade nichts ueber PayPal laufen). Die Zuordnungsseite
|
||||
// bleibt fuer offene Zahlungen erreichbar, dort entscheidet der Mandant.
|
||||
require_once __DIR__ . '/features.php';
|
||||
if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
||||
return ['status' => 'parked', 'payment_id' => $paymentId];
|
||||
}
|
||||
|
||||
// Eindeutiger Match -> automatisch buchen.
|
||||
$participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null);
|
||||
$participant = paypal_match_participant(
|
||||
$pdo,
|
||||
$tenantId,
|
||||
(string) ($parsed['payer_name'] ?? ''),
|
||||
$parsed['note'] ?? null,
|
||||
$parsed['payer_email'] ?? null
|
||||
);
|
||||
if ($participant === null) {
|
||||
return ['status' => 'unmatched', 'payment_id' => $paymentId];
|
||||
}
|
||||
@@ -219,19 +331,72 @@ function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUse
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine rohe PayPal-Mail vollstaendig: Tenant per Plus-Token,
|
||||
* Absenderpruefung, Parsing, Abgleich. Fuer den IMAP-Cron und Tests.
|
||||
* Vorschau ohne jeden Schreibzugriff: was wuerde mit dieser Zahlung
|
||||
* passieren? Gedacht fuer den Probelauf des Abrufskripts (--dry-run). Der
|
||||
* Probelauf soll wirklich nichts anfassen - eine Vorschau, die im
|
||||
* Hintergrund bucht, waere schlimmer als gar keine.
|
||||
*
|
||||
* @param array $parsed Ergebnis von paypal_parse_notification()
|
||||
* @return array{status:string, participant?:string, net_cents?:int}
|
||||
*/
|
||||
function paypal_preview(PDO $pdo, int $tenantId, array $parsed): array
|
||||
{
|
||||
$code = (string) ($parsed['transaction_code'] ?? '');
|
||||
if ($code === '') {
|
||||
return ['status' => 'no_code'];
|
||||
}
|
||||
$netCents = (int) ($parsed['net_cents'] ?? 0);
|
||||
if ($netCents <= 0) {
|
||||
return ['status' => 'no_amount'];
|
||||
}
|
||||
|
||||
// Dedup wie beim echten Lauf: der Transaktionscode ist global eindeutig.
|
||||
$stmt = $pdo->prepare('SELECT id FROM paypal_payments WHERE transaction_code = ? LIMIT 1');
|
||||
$stmt->execute([$code]);
|
||||
if ($stmt->fetchColumn() !== false) {
|
||||
return ['status' => 'duplicate'];
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/features.php';
|
||||
if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
||||
return ['status' => 'would_park', 'net_cents' => $netCents];
|
||||
}
|
||||
|
||||
$participant = paypal_match_participant(
|
||||
$pdo,
|
||||
$tenantId,
|
||||
(string) ($parsed['payer_name'] ?? ''),
|
||||
$parsed['note'] ?? null,
|
||||
$parsed['payer_email'] ?? null
|
||||
);
|
||||
if ($participant === null) {
|
||||
return ['status' => 'would_queue', 'net_cents' => $netCents];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'would_book',
|
||||
'participant' => (string) $participant['display_name'],
|
||||
'net_cents' => $netCents,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine rohe PayPal-Mail vollstaendig: Mandant per Plus-Token der
|
||||
* Empfangsadresse, Parsing, Abgleich. Fuer den IMAP-Cron und Tests.
|
||||
*
|
||||
* Der Absender wird nicht ausgewertet - siehe Kommentar im Rumpf.
|
||||
*
|
||||
* @param string $recipient Empfaengeradresse mit Plus-Token
|
||||
* @param string $fromHeader From-Header der Mail
|
||||
* @param string $rawBody (dekodierter) Mail-Body
|
||||
* @return array{status:string, tenant_id?:int}
|
||||
*/
|
||||
function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, string $rawBody): array
|
||||
function paypal_process_raw(PDO $pdo, string $recipient, string $rawBody, bool $previewOnly = false): array
|
||||
{
|
||||
if (!preg_match('/@paypal\.(de|com)/i', $fromHeader)) {
|
||||
return ['status' => 'not_from_paypal'];
|
||||
}
|
||||
// Der Absender wird bewusst nicht geprueft: Zahlungsmails erreichen die
|
||||
// Kaffeeliste auch ueber manuelle Weiterleitungen, und dann steht dort
|
||||
// die Adresse des Weiterleitenden statt PayPal. Massgeblich ist allein
|
||||
// die Empfangsadresse - ihr Token bestimmt den Mandanten und ist damit
|
||||
// zugleich das Geheimnis, das den Zugang schuetzt.
|
||||
$token = paypal_inbox_extract_token($recipient);
|
||||
if ($token === null) {
|
||||
return ['status' => 'no_token'];
|
||||
@@ -245,7 +410,18 @@ function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, str
|
||||
return ['status' => 'not_a_payment'];
|
||||
}
|
||||
|
||||
$result = paypal_reconcile($pdo, $tenantId, $parsed);
|
||||
// Die eigene Eingangsadresse steht in jeder weitergeleiteten Mail und darf
|
||||
// niemals als Adresse des Zahlers durchgehen - sonst wuerde eine Zahlung
|
||||
// dem Mitglied gutgeschrieben, dem diese Adresse gehoert. Die Pruefung
|
||||
// haengt bewusst an der tatsaechlichen Empfaengeradresse und nicht nur an
|
||||
// der Konfiguration.
|
||||
if (paypal_same_mailbox((string) ($parsed['payer_email'] ?? ''), $recipient)) {
|
||||
$parsed['payer_email'] = null;
|
||||
}
|
||||
|
||||
$result = $previewOnly
|
||||
? paypal_preview($pdo, $tenantId, $parsed)
|
||||
: paypal_reconcile($pdo, $tenantId, $parsed);
|
||||
$result['tenant_id'] = $tenantId;
|
||||
|
||||
return $result;
|
||||
@@ -259,7 +435,7 @@ function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, str
|
||||
function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, transaction_code, payer_name, note, net_cents, paid_at, created_at
|
||||
"SELECT id, transaction_code, payer_name, payer_email, note, net_cents, paid_at, created_at
|
||||
FROM paypal_payments
|
||||
WHERE tenant_id = ? AND status = 'unmatched'
|
||||
ORDER BY created_at DESC, id DESC"
|
||||
@@ -275,6 +451,52 @@ function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array
|
||||
*
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
/**
|
||||
* Soll der Zahlername als PayPal-Name beim Mitglied hinterlegt werden?
|
||||
* Liefert den zu speichernden Namen oder null, wenn nichts zu lernen ist.
|
||||
*
|
||||
* Zurueckhaltend absichtlich: ein bereits gepflegter PayPal-Name wird nie
|
||||
* ueberschrieben, ein Name gleich dem Anzeigenamen bringt nichts (der wird
|
||||
* ohnehin geprueft), und wenn der Name im Mandanten nicht eindeutig waere,
|
||||
* wuerde das Merken die automatische Zuordnung sogar blockieren - dann
|
||||
* lieber weiter von Hand zuordnen.
|
||||
*/
|
||||
function paypal_remember_payer_name(PDO $pdo, int $tenantId, int $participantId, string $payerName): ?string
|
||||
{
|
||||
$payerName = trim($payerName);
|
||||
if ($payerName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT display_name, paypal_name FROM participants WHERE id = ? AND tenant_id = ?');
|
||||
$stmt->execute([$participantId, $tenantId]);
|
||||
$participant = $stmt->fetch();
|
||||
if ($participant === false) {
|
||||
return null;
|
||||
}
|
||||
if (trim((string) ($participant['paypal_name'] ?? '')) !== '') {
|
||||
return null;
|
||||
}
|
||||
if (strcasecmp(trim((string) $participant['display_name']), $payerName) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wuerde der Name auf ein weiteres Mitglied passen, bliebe die Zuordnung
|
||||
// mehrdeutig (imports_find_participant verlangt genau einen Treffer).
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM participants
|
||||
WHERE tenant_id = ?
|
||||
AND id <> ?
|
||||
AND (LOWER(paypal_name) = LOWER(?) OR LOWER(display_name) = LOWER(?))'
|
||||
);
|
||||
$stmt->execute([$tenantId, $participantId, $payerName, $payerName]);
|
||||
if ((int) $stmt->fetchColumn() > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $payerName;
|
||||
}
|
||||
|
||||
function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $participantId, ?int $actorUserId): array
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT id, net_cents, status, payer_name, note, paid_at FROM paypal_payments WHERE id = ? AND tenant_id = ?");
|
||||
@@ -290,11 +512,22 @@ function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $par
|
||||
return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.'];
|
||||
}
|
||||
|
||||
// Aus der Handarbeit lernen: heisst jemand bei PayPal anders als in der
|
||||
// Kaffeeliste, greift die automatische Zuordnung nie - denn sie verlangt
|
||||
// exakte Uebereinstimmung. Nach der ersten manuellen Zuordnung wird der
|
||||
// Zahlername deshalb beim Mitglied hinterlegt, damit die naechste Zahlung
|
||||
// von allein ankommt.
|
||||
$gemerkterName = paypal_remember_payer_name($pdo, $tenantId, $participantId, (string) $payment['payer_name']);
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$ledgerId = paypal_book_payment($pdo, $tenantId, $participant, (int) $payment['net_cents'], $actorUserId, paypal_booking_note($payment));
|
||||
$pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?")
|
||||
->execute([$participantId, $ledgerId, $paymentId]);
|
||||
if ($gemerkterName !== null) {
|
||||
$pdo->prepare('UPDATE participants SET paypal_name = ? WHERE id = ? AND tenant_id = ?')
|
||||
->execute([$gemerkterName, $participantId, $tenantId]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
@@ -307,9 +540,10 @@ function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $par
|
||||
app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.manual_assigned', 'paypal_payment', $paymentId, [
|
||||
'participant_id' => $participantId,
|
||||
'net_cents' => (int) $payment['net_cents'],
|
||||
'learned_paypal_name' => $gemerkterName,
|
||||
]);
|
||||
|
||||
return ['ok' => true];
|
||||
return ['ok' => true, 'learned_paypal_name' => $gemerkterName];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,3 +562,54 @@ function paypal_ignore_payment(PDO $pdo, int $tenantId, int $paymentId, ?int $ac
|
||||
|
||||
return ['ok' => false, 'error' => 'Diese Zahlung ist nicht (mehr) offen.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzahl der offenen Zahlungen in der Warteschlange. Bewusst als eigene
|
||||
* Zaehl-Abfrage neben paypal_fetch_unmatched(): die Navigation braucht nur
|
||||
* die Information "gibt es noch etwas zu tun", nicht die Datensaetze.
|
||||
*/
|
||||
function paypal_count_unmatched(PDO $pdo, int $tenantId): int
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (array_key_exists($tenantId, $cache)) {
|
||||
return $cache[$tenantId];
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM paypal_payments WHERE tenant_id = ? AND status = 'unmatched'"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$cache[$tenantId] = (int)$stmt->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
// Fehlt die Tabelle, gibt es auch keine Warteschlange - die
|
||||
// Navigation darf daran nicht scheitern.
|
||||
$cache[$tenantId] = 0;
|
||||
}
|
||||
|
||||
return $cache[$tenantId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf die Zuordnungsseite geoeffnet werden? Die Funktion kennt drei Faelle:
|
||||
* der Betreiber hat gesperrt (nein), der Mandant bietet PayPal an (ja) - und
|
||||
* dazwischen der Fall, dass der Mandant PayPal gerade abgeschaltet hat,
|
||||
* aber noch Zahlungen unzugeordnet in der Warteschlange liegen. Die waeren
|
||||
* sonst weder ueber das Menue noch ueber die URL erreichbar; die Seite bleibt
|
||||
* deshalb offen, bis die Warteschlange leer ist.
|
||||
*/
|
||||
function paypal_inbox_accessible(PDO $pdo, int $tenantId): bool
|
||||
{
|
||||
require_once __DIR__ . '/features.php';
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'paypal_inbox')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return paypal_count_unmatched($pdo, $tenantId) > 0;
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wandelt den (ggf. UTF-16-kodierten) Mail-Body in normalisierten UTF-8-Text um:
|
||||
* Skript/Style raus, Tags durch Leerzeichen ersetzt, Entities dekodiert, NBSP
|
||||
* und Mehrfach-Whitespace zusammengefasst.
|
||||
* Wandelt den (ggf. UTF-16- oder Windows-1252-kodierten) Mail-Body in
|
||||
* normalisierten UTF-8-Text um: Skript/Style raus, Tags durch Leerzeichen
|
||||
* ersetzt, Entities dekodiert, NBSP und Mehrfach-Whitespace zusammengefasst.
|
||||
*/
|
||||
function paypal_mail_to_text(string $body): string
|
||||
{
|
||||
@@ -27,6 +27,16 @@ function paypal_mail_to_text(string $body): string
|
||||
if ($converted !== false) {
|
||||
$body = $converted;
|
||||
}
|
||||
} elseif (preg_match('//u', $body) !== 1) {
|
||||
// Outlook/Hotmail kennzeichnet weitergeleitete Nachrichten teils als
|
||||
// UTF-8, liefert den Body aber tatsaechlich in Windows-1252. Dann
|
||||
// schlagen alle folgenden Unicode-Regulaerausdruecke still fehl. Das
|
||||
// Eurozeichen (0x80) ist zugleich ein typisches Erkennungsmerkmal fuer
|
||||
// Windows-1252 statt ISO-8859-1.
|
||||
$converted = @iconv('Windows-1252', 'UTF-8//IGNORE', $body);
|
||||
if ($converted !== false) {
|
||||
$body = $converted;
|
||||
}
|
||||
}
|
||||
|
||||
$body = preg_replace('#<(script|style)[^>]*>.*?</\1>#is', ' ', $body) ?? $body;
|
||||
@@ -103,11 +113,69 @@ function paypal_extract_labeled_amount(string $text, string $labelRegex): ?int
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailadresse des Zahlers aus der Benachrichtigung.
|
||||
*
|
||||
* Bewusst eng gefasst: In einer weitergeleiteten Mail stehen mehrere
|
||||
* Adressen - die von PayPal selbst, die Eingangsadresse der Kaffeeliste und
|
||||
* je nach Weiterleitung auch die des urspruenglichen Empfaengers. Wuerde
|
||||
* einfach die erste beste genommen, koennte eine Zahlung beim Kassenwart
|
||||
* statt beim Zahler landen. Genommen wird deshalb nur eine Adresse, die
|
||||
* nahe am Namen des Zahlers steht (PayPal schreibt sie dort hin), und auch
|
||||
* die erst nach Abzug aller bekannten Fremdadressen.
|
||||
*
|
||||
* @param string $text Bereits per paypal_mail_to_text() normalisierter Text
|
||||
* @param string $payer Erkannter Name des Zahlers
|
||||
*/
|
||||
function paypal_extract_payer_email(string $text, string $payer): ?string
|
||||
{
|
||||
if (!preg_match_all('/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/u', $text, $treffer, PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Eingangsadresse der Kaffeeliste (zahlungen+<token>@…) und alles von
|
||||
// PayPal selbst scheiden aus.
|
||||
// Der Parser wird auch aus Testskripten ohne geladene Umgebung benutzt.
|
||||
$inboxBase = function_exists('app_env') ? (string) (app_env('PAYPAL_INBOX_BASE') ?? '') : '';
|
||||
$inboxLokalteil = $inboxBase !== '' ? strtolower(explode('@', $inboxBase)[0]) : '';
|
||||
|
||||
$namePosition = stripos($text, $payer);
|
||||
|
||||
$kandidaten = [];
|
||||
foreach ($treffer[0] as [$adresse, $position]) {
|
||||
$adresse = strtolower(rtrim($adresse, '.'));
|
||||
$domain = substr($adresse, strpos($adresse, '@') + 1);
|
||||
$lokalteil = substr($adresse, 0, strpos($adresse, '@'));
|
||||
|
||||
if (preg_match('/(^|\.)paypal\.[a-z.]+$/', $domain)) {
|
||||
continue;
|
||||
}
|
||||
if ($inboxLokalteil !== '' && str_starts_with($lokalteil, $inboxLokalteil)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$abstand = $namePosition === false ? PHP_INT_MAX : abs($position - $namePosition);
|
||||
$kandidaten[$adresse] = min($kandidaten[$adresse] ?? PHP_INT_MAX, $abstand);
|
||||
}
|
||||
|
||||
if ($kandidaten === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
asort($kandidaten);
|
||||
$beste = array_key_first($kandidaten);
|
||||
|
||||
// Steht keine Adresse in der Naehe des Zahlernamens, ist die Zuordnung zu
|
||||
// unsicher - dann lieber keine liefern als die falsche.
|
||||
return $kandidaten[$beste] <= 400 ? $beste : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst eine PayPal-Zahlungseingangs-Mail.
|
||||
*
|
||||
* @return array{
|
||||
* payer_name: string,
|
||||
* payer_email: ?string,
|
||||
* transaction_code: ?string,
|
||||
* gross_cents: int,
|
||||
* fee_cents: ?int,
|
||||
@@ -163,6 +231,7 @@ function paypal_parse_notification(string $body): ?array
|
||||
|
||||
return [
|
||||
'payer_name' => $payer,
|
||||
'payer_email' => paypal_extract_payer_email($text, $payer),
|
||||
'transaction_code' => $code,
|
||||
'gross_cents' => $grossCents,
|
||||
'fee_cents' => $feeCents,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Wasserzeichen des kostenlosen Tarifs: eine kleine Marke unten rechts, die
|
||||
* auf die Kaffeeliste verweist. Sie erscheint nur im Tarif "free" - wer
|
||||
* zahlt, bekommt seine Oberflaeche ohne fremdes Logo.
|
||||
*
|
||||
* Bewusst getrennt von app/branding.php: dort geht es um das Logo *des
|
||||
* Mandanten*, hier um das Logo *des Anbieters*. Beide koennen gleichzeitig
|
||||
* sichtbar sein (Kopfzeile oben, Wasserzeichen unten rechts).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tarif eines Mandanten, ohne Seiteneffekt. Absichtlich nicht
|
||||
* billing_fetch_or_init(): das Wasserzeichen wird auf jeder Seite geprueft
|
||||
* und darf dabei keine Zeile anlegen. Fehlt der Datensatz, gilt - wie beim
|
||||
* Anlegen - "free".
|
||||
*/
|
||||
function plan_watermark_plan_code(PDO $pdo, int $tenantId): string
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (array_key_exists($tenantId, $cache)) {
|
||||
return $cache[$tenantId];
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT plan_code FROM tenant_billing WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$planCode = $stmt->fetchColumn();
|
||||
$cache[$tenantId] = $planCode === false ? 'free' : (string)$planCode;
|
||||
} catch (Throwable $e) {
|
||||
// Laesst sich der Tarif nicht lesen, bleibt die Oberflaeche sauber -
|
||||
// ein Wasserzeichen bei einem zahlenden Kunden waere der schlimmere
|
||||
// Fehler als ein fehlendes bei einem kostenlosen.
|
||||
$cache[$tenantId] = 'unknown';
|
||||
}
|
||||
|
||||
return $cache[$tenantId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt dieser Mandant das Wasserzeichen? Im kostenlosen Tarif immer - es ist
|
||||
* der Gegenwert fuer die kostenlose Nutzung und deshalb nicht abschaltbar.
|
||||
* Alle anderen entscheiden ueber die Mandant-Einstellung
|
||||
* `watermark_enabled` (Standard: an).
|
||||
*/
|
||||
function plan_watermark_visible(PDO $pdo, int $tenantId): bool
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$planCode = plan_watermark_plan_code($pdo, $tenantId);
|
||||
if ($planCode === 'free') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($planCode === 'unknown') {
|
||||
return false;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/features.php';
|
||||
|
||||
return app_tenant_switch_enabled($pdo, $tenantId, 'watermark_enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wortmarke der Kaffeeliste als Inline-SVG. Als Inline-Grafik statt als
|
||||
* Bilddatei, damit das Wasserzeichen ohne zusaetzlichen Request auskommt,
|
||||
* auf jedem Bildschirm scharf bleibt und die Farbe der Umgebung uebernimmt.
|
||||
*/
|
||||
function plan_watermark_logo_svg(): string
|
||||
{
|
||||
return '<svg class="plan-watermark-logo" viewBox="0 0 24 24" width="18" height="18" '
|
||||
. 'aria-hidden="true" focusable="false" fill="none" stroke="currentColor" '
|
||||
. 'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
|
||||
. '<path d="M4 9h13v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9Z"/>'
|
||||
. '<path d="M17 10.5h1.5a2.5 2.5 0 0 1 0 5H17"/>'
|
||||
. '<path d="M8 5.5c0-1 1-1.4 1-2.5M12 5.5c0-1 1-1.4 1-2.5"/>'
|
||||
. '</svg>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschriftung des Wasserzeichens: die Marketing-Domain ohne Schema
|
||||
* ("kaffeeliste.de"). Laesst sie sich nicht ermitteln, bleibt der Name der
|
||||
* App uebrig. Wird von der Web-Ansicht und vom PDF-Ausdruck gemeinsam
|
||||
* genutzt, damit beide dieselbe Adresse nennen.
|
||||
*/
|
||||
function plan_watermark_label(): string
|
||||
{
|
||||
$url = app_marketing_url();
|
||||
|
||||
return $url !== '' ? (string)preg_replace('#^https?://#i', '', $url) : 'Kaffeeliste';
|
||||
}
|
||||
|
||||
/**
|
||||
* Markup des Wasserzeichens. Ohne erreichbare Marketing-Domain (CLI,
|
||||
* ungewoehnliche Hosts) bleibt es bei Logo und Namen ohne Link - ein
|
||||
* Verweis ins Leere waere schlechter als keiner.
|
||||
*/
|
||||
function plan_watermark_html(): string
|
||||
{
|
||||
$url = app_marketing_url();
|
||||
$label = plan_watermark_label();
|
||||
$inner = plan_watermark_logo_svg()
|
||||
. '<span class="plan-watermark-text">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>';
|
||||
|
||||
if ($url === '') {
|
||||
return '<div class="plan-watermark" title="Kaffeeliste – die digitale Kaffeekasse">' . $inner . '</div>';
|
||||
}
|
||||
|
||||
return '<a class="plan-watermark" href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '"'
|
||||
. ' target="_blank" rel="noopener noreferrer"'
|
||||
. ' title="Kaffeeliste – die digitale Kaffeekasse">' . $inner . '</a>';
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
require_once __DIR__ . '/audit.php';
|
||||
require_once __DIR__ . '/totp.php';
|
||||
|
||||
/**
|
||||
* Platform-admin access is deliberately a separate concept from
|
||||
@@ -44,6 +45,15 @@ function app_require_platform_admin(PDO $pdo): array
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
// Das Back-Office sieht alle Mandanten - fuer diese Konten laesst sich ein
|
||||
// zweiter Faktor erzwingen. Bewusst als Weiterleitung auf die Einrichtung
|
||||
// statt als harte Sperre: sonst koennte der Schalter den einzigen
|
||||
// Platform-Admin dauerhaft aussperren.
|
||||
if (app_totp_required_for_platform_admins() && !app_totp_is_active($pdo, (int)$user['user_id'])) {
|
||||
header('Location: zwei-faktor-einrichten.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Vollständiger Katalog aller Tabellen, die zur Kaffeeliste gehören. Die
|
||||
* zentrale SQL-Umschreibung beschränkt sich bewusst auf diese feste Liste;
|
||||
* ein vom Request gelieferter Bezeichner kann dadurch nie in SQL gelangen.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function app_database_table_names(): array
|
||||
{
|
||||
return [
|
||||
'schema_migrations',
|
||||
'CoffeeSurveyVotedEmails',
|
||||
'CoffeeSurveyResponses',
|
||||
'dev_baseline_metadata',
|
||||
'kl_Kaffeeverbrauch',
|
||||
'kl_Einzahlungen',
|
||||
'kl_Mitarbeiter',
|
||||
'kl_hinweise',
|
||||
'kl_config',
|
||||
'payment_import_batches',
|
||||
'payment_import_rows',
|
||||
'stripe_webhook_events',
|
||||
'totp_recovery_codes',
|
||||
'public_page_views',
|
||||
'rate_limit_attempts',
|
||||
'tenant_memberships',
|
||||
'legal_acceptances',
|
||||
'outbound_emails',
|
||||
'user_auth_tokens',
|
||||
'tenant_settings',
|
||||
'platform_admins',
|
||||
'tenant_features',
|
||||
'paypal_payments',
|
||||
'ledger_entries',
|
||||
'legal_requests',
|
||||
'tenant_domains',
|
||||
'tenant_billing',
|
||||
'faq_entries',
|
||||
'participants',
|
||||
'audit_log',
|
||||
'notices',
|
||||
'tenants',
|
||||
'users',
|
||||
];
|
||||
}
|
||||
|
||||
function app_validate_db_table_prefix(string $prefix): string
|
||||
{
|
||||
$prefix = trim($prefix);
|
||||
if ($prefix === '') {
|
||||
return '';
|
||||
}
|
||||
if (strlen($prefix) > 16 || preg_match('/^[a-z][a-z0-9_]*_$/', $prefix) !== 1) {
|
||||
throw new RuntimeException(
|
||||
'DB_TABLE_PREFIX muss leer sein oder aus höchstens 16 Kleinbuchstaben, Ziffern und Unterstrichen bestehen und mit Unterstrich enden.'
|
||||
);
|
||||
}
|
||||
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
function app_db_table_prefix(): string
|
||||
{
|
||||
$prefix = app_validate_db_table_prefix((string)app_env('DB_TABLE_PREFIX', ''));
|
||||
if ($prefix === '' && !app_is_dev()) {
|
||||
throw new RuntimeException('DB_TABLE_PREFIX darf außerhalb der Entwicklungsumgebung nicht leer sein.');
|
||||
}
|
||||
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
function app_db_table_name(string $baseName, ?string $prefix = null): string
|
||||
{
|
||||
if (!in_array($baseName, app_database_table_names(), true)) {
|
||||
throw new InvalidArgumentException('Unbekannte Anwendungstabelle: ' . $baseName);
|
||||
}
|
||||
|
||||
return app_validate_db_table_prefix($prefix ?? app_db_table_prefix()) . $baseName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt ausschließlich bekannte Tabellen- und FK-Namen um. Das gilt auch
|
||||
* innerhalb der dynamischen DDL-Strings in den Migrationen 0003/0008 und für
|
||||
* deren INFORMATION_SCHEMA-Vergleiche.
|
||||
*/
|
||||
function app_prefix_database_sql(string $sql, ?string $prefix = null): string
|
||||
{
|
||||
$prefix = app_validate_db_table_prefix($prefix ?? app_db_table_prefix());
|
||||
if ($prefix === '') {
|
||||
return $sql;
|
||||
}
|
||||
|
||||
static $tablePattern = null;
|
||||
if ($tablePattern === null) {
|
||||
$tables = app_database_table_names();
|
||||
usort($tables, static fn(string $a, string $b): int => strlen($b) <=> strlen($a));
|
||||
$tablePattern = '/(?<![A-Za-z0-9_])('
|
||||
. implode('|', array_map(static fn(string $table): string => preg_quote($table, '/'), $tables))
|
||||
. ')(?![A-Za-z0-9_])/i';
|
||||
}
|
||||
$sql = preg_replace_callback(
|
||||
$tablePattern,
|
||||
static fn(array $match): string => $prefix . $match[0],
|
||||
$sql
|
||||
) ?? $sql;
|
||||
|
||||
// MySQL verlangt innerhalb einer Datenbank eindeutige Namen für
|
||||
// Fremdschlüssel-Constraints. Die Migrationen verwenden durchgängig
|
||||
// sprechende fk_*-Namen; sie brauchen daher ebenfalls den Umgebungs-Prefix.
|
||||
$sql = preg_replace_callback(
|
||||
'/(?<![A-Za-z0-9_])(fk_[A-Za-z0-9_]+)(?![A-Za-z0-9_])/i',
|
||||
static fn(array $match): string => $prefix . $match[1],
|
||||
$sql
|
||||
) ?? $sql;
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
final class AppPrefixedPDO extends PDO
|
||||
{
|
||||
private string $tablePrefix = '';
|
||||
|
||||
public function setTablePrefix(string $prefix): void
|
||||
{
|
||||
$this->tablePrefix = app_validate_db_table_prefix($prefix);
|
||||
}
|
||||
|
||||
public function tablePrefix(): string
|
||||
{
|
||||
return $this->tablePrefix;
|
||||
}
|
||||
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false
|
||||
{
|
||||
return parent::prepare(app_prefix_database_sql($query, $this->tablePrefix), $options);
|
||||
}
|
||||
|
||||
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false
|
||||
{
|
||||
$query = app_prefix_database_sql($query, $this->tablePrefix);
|
||||
if ($fetchMode === null) {
|
||||
return parent::query($query);
|
||||
}
|
||||
|
||||
return parent::query($query, $fetchMode, ...$fetchModeArgs);
|
||||
}
|
||||
|
||||
public function exec(string $statement): int|false
|
||||
{
|
||||
return parent::exec(app_prefix_database_sql($statement, $this->tablePrefix));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/database.php';
|
||||
|
||||
/**
|
||||
* Fester Katalog der gezaehlten oeffentlichen Seiten. Ein Request-Wert darf
|
||||
* nie zum Seitenschluessel werden: So koennen insbesondere Query-Parameter,
|
||||
* Token oder frei eingegebene Inhalte nicht in der Statistik landen.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function app_public_page_view_labels(): array
|
||||
{
|
||||
return [
|
||||
'landing' => 'Startseite',
|
||||
'prices' => 'Preise',
|
||||
'register' => 'Registrierung',
|
||||
'login' => 'Login',
|
||||
'privacy' => 'Datenschutz',
|
||||
'imprint' => 'Impressum',
|
||||
'terms' => 'AGB',
|
||||
'dpa' => 'AVV',
|
||||
'withdrawal_information' => 'Widerrufsbelehrung',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erhoeht ausschliesslich einen aggregierten Tageszaehler. Es werden keine
|
||||
* Request-Metadaten gelesen oder gespeichert. Schreibfehler duerfen eine
|
||||
* oeffentliche Seite nicht unbenutzbar machen, etwa waehrend eines Deploys,
|
||||
* bei dem die Migration wenige Sekunden nach dem Code eingespielt wird.
|
||||
*/
|
||||
function app_record_public_page_view(string $pageKey, ?PDO $pdo = null): void
|
||||
{
|
||||
// Ein explizit injiziertes PDO ist nur fuer den CLI-Regressionstest
|
||||
// vorgesehen. Normale CLI-Aufrufe sollen keine Seitenansicht erzeugen.
|
||||
if (($pdo === null && PHP_SAPI === 'cli') || ($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
|
||||
return;
|
||||
}
|
||||
if (!array_key_exists($pageKey, app_public_page_view_labels())) {
|
||||
throw new InvalidArgumentException('Unbekannter öffentlicher Seitenschlüssel.');
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = ($pdo ?? app_db_pdo())->prepare(
|
||||
'INSERT INTO public_page_views (view_date, page_key, view_count)
|
||||
VALUES (CURRENT_DATE, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE view_count = view_count + 1'
|
||||
);
|
||||
$stmt->execute([$pageKey]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('Anonymer Seitenzähler konnte nicht aktualisiert werden: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* totals: array{today: int, last_30_days: int, all_time: int},
|
||||
* pages: list<array{page_key: string, label: string, today: int, last_30_days: int, all_time: int}>
|
||||
* }
|
||||
*/
|
||||
function app_public_page_view_summary(PDO $pdo): array
|
||||
{
|
||||
$totalsRow = $pdo->query(
|
||||
'SELECT
|
||||
COALESCE(SUM(CASE WHEN view_date = CURRENT_DATE THEN view_count ELSE 0 END), 0) AS today,
|
||||
COALESCE(SUM(CASE WHEN view_date >= CURRENT_DATE - INTERVAL 29 DAY THEN view_count ELSE 0 END), 0) AS last_30_days,
|
||||
COALESCE(SUM(view_count), 0) AS all_time
|
||||
FROM public_page_views'
|
||||
)->fetch() ?: [];
|
||||
|
||||
$pageRows = $pdo->query(
|
||||
'SELECT
|
||||
page_key,
|
||||
COALESCE(SUM(CASE WHEN view_date = CURRENT_DATE THEN view_count ELSE 0 END), 0) AS today,
|
||||
COALESCE(SUM(CASE WHEN view_date >= CURRENT_DATE - INTERVAL 29 DAY THEN view_count ELSE 0 END), 0) AS last_30_days,
|
||||
COALESCE(SUM(view_count), 0) AS all_time
|
||||
FROM public_page_views
|
||||
GROUP BY page_key
|
||||
ORDER BY all_time DESC, page_key ASC'
|
||||
)->fetchAll();
|
||||
|
||||
$labels = app_public_page_view_labels();
|
||||
$pages = [];
|
||||
foreach ($pageRows as $row) {
|
||||
$pageKey = (string)$row['page_key'];
|
||||
$pages[] = [
|
||||
'page_key' => $pageKey,
|
||||
'label' => $labels[$pageKey] ?? $pageKey,
|
||||
'today' => (int)$row['today'],
|
||||
'last_30_days' => (int)$row['last_30_days'],
|
||||
'all_time' => (int)$row['all_time'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'totals' => [
|
||||
'today' => (int)($totalsRow['today'] ?? 0),
|
||||
'last_30_days' => (int)($totalsRow['last_30_days'] ?? 0),
|
||||
'all_time' => (int)($totalsRow['all_time'] ?? 0),
|
||||
],
|
||||
'pages' => $pages,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Betreiber-Benachrichtigungen ueber Pushover. Bewusst als reine
|
||||
* "fire and forget"-Meldung ausgelegt: schlaegt der Versand fehl, darf das
|
||||
* den ausloesenden Vorgang (z. B. eine Registrierung) niemals abbrechen -
|
||||
* der Kunde haette sonst kein Konto, nur weil der Push nicht rausging.
|
||||
*
|
||||
* Konfiguration ueber PUSHOVER_TOKEN (App-Token) und PUSHOVER_USER
|
||||
* (User- oder Group-Key) in env.local.php. Fehlt eines davon, ist die
|
||||
* Funktion still deaktiviert - lokal und auf Staging passiert dann nichts.
|
||||
*/
|
||||
function pushover_is_configured(): bool
|
||||
{
|
||||
return (string)app_env('PUSHOVER_TOKEN', '') !== ''
|
||||
&& (string)app_env('PUSHOVER_USER', '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, error: ?string}
|
||||
*/
|
||||
function pushover_notify(string $title, string $message, ?string $url = null, ?string $urlTitle = null): array
|
||||
{
|
||||
if (!pushover_is_configured()) {
|
||||
return ['ok' => false, 'error' => 'Pushover ist nicht konfiguriert.'];
|
||||
}
|
||||
|
||||
$params = [
|
||||
'token' => (string)app_env('PUSHOVER_TOKEN', ''),
|
||||
'user' => (string)app_env('PUSHOVER_USER', ''),
|
||||
// Pushover kappt laengere Werte serverseitig; wir kuerzen selbst,
|
||||
// damit die Meldung nicht mitten im Wort abbricht.
|
||||
'title' => mb_substr($title, 0, 250),
|
||||
'message' => mb_substr($message, 0, 1024),
|
||||
];
|
||||
if ($url !== null && $url !== '') {
|
||||
$params['url'] = $url;
|
||||
$params['url_title'] = $urlTitle ?? $url;
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/x-www-form-urlencoded',
|
||||
'content' => http_build_query($params),
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$responseBody = @file_get_contents('https://api.pushover.net/1/messages.json', false, $context);
|
||||
$status = 0;
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
|
||||
$status = (int)$m[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ($responseBody === false) {
|
||||
error_log('Pushover: keine Antwort von api.pushover.net');
|
||||
return ['ok' => false, 'error' => 'Keine Antwort von Pushover.'];
|
||||
}
|
||||
|
||||
$data = json_decode($responseBody, true);
|
||||
if (!is_array($data) || $status >= 400 || (int)($data['status'] ?? 0) !== 1) {
|
||||
$errors = is_array($data['errors'] ?? null) ? implode('; ', $data['errors']) : 'HTTP ' . $status;
|
||||
error_log('Pushover fehlgeschlagen: ' . $errors);
|
||||
return ['ok' => false, 'error' => $errors];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldung ueber eine abgeschlossene Selbstregistrierung. Wird nur aus
|
||||
* register.php aufgerufen, nicht aus saas_register_tenant_owner() selbst -
|
||||
* sonst wuerden die check-*-Skripte in scripts/ bei jedem Testlauf Pushes
|
||||
* ausloesen.
|
||||
*/
|
||||
function pushover_notify_new_tenant(string $tenantName, string $tenantSlug, string $email, string $customerType): array
|
||||
{
|
||||
$type = $customerType === 'b2c' ? 'Privatkunde' : 'Geschäftskunde';
|
||||
$baseUrl = rtrim((string)app_env('APP_BASE_URL', ''), '/');
|
||||
|
||||
return pushover_notify(
|
||||
'Neue Registrierung: ' . $tenantName,
|
||||
sprintf("%s (%s)\nKürzel: %s\nE-Mail: %s", $tenantName, $type, $tenantSlug, $email),
|
||||
$baseUrl !== '' ? $baseUrl . '/backoffice.php' : null,
|
||||
'Back-Office öffnen'
|
||||
);
|
||||
}
|
||||
+3
-4
@@ -20,10 +20,9 @@ function app_rate_limit_check(PDO $pdo, string $bucket, int $maxAttempts, int $w
|
||||
{
|
||||
$pdo->prepare('INSERT INTO rate_limit_attempts (bucket) VALUES (?)')->execute([$bucket]);
|
||||
|
||||
// Opportunistic cleanup so the table doesn't grow unbounded; scoped to
|
||||
// this bucket to keep each call cheap.
|
||||
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ? AND created_at < DATE_SUB(NOW(), INTERVAL ? SECOND)')
|
||||
->execute([$bucket, $windowSeconds]);
|
||||
// Globaler Maximalhorizont: unabhängig davon, ob derselbe Bucket je wieder
|
||||
// benutzt wird, bleibt keine IP-basierte Rate-Limit-Zeile länger als 24h.
|
||||
$pdo->exec('DELETE FROM rate_limit_attempts WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM rate_limit_attempts WHERE bucket = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)'
|
||||
|
||||
+96
-12
@@ -6,6 +6,7 @@ require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/database.php';
|
||||
require_once __DIR__ . '/faq.php';
|
||||
require_once __DIR__ . '/billing.php';
|
||||
require_once __DIR__ . '/legal.php';
|
||||
|
||||
function saas_email_norm(string $email): string
|
||||
{
|
||||
@@ -55,7 +56,7 @@ function saas_normalize_host(?string $host): string
|
||||
|
||||
function saas_primary_app_host(): string
|
||||
{
|
||||
return saas_normalize_host(app_env('APP_PRIMARY_HOST', ''));
|
||||
return saas_normalize_host(app_env('APP_PRIMARY_HOST', app_primary_host() ?? ''));
|
||||
}
|
||||
|
||||
function saas_is_primary_app_host(?string $host = null): bool
|
||||
@@ -92,6 +93,7 @@ function saas_resolve_tenant_domain(PDO $pdo, ?string $host = null): ?array
|
||||
WHERE td.domain_norm = ?
|
||||
AND td.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$host, 'active', 'active']);
|
||||
@@ -166,6 +168,8 @@ function saas_current_user(?PDO $pdo = null): ?array
|
||||
t.id AS tenant_id,
|
||||
t.slug AS tenant_slug,
|
||||
t.name AS tenant_name,
|
||||
t.customer_type,
|
||||
t.contract_ends_at,
|
||||
t.status AS tenant_status,
|
||||
tm.role,
|
||||
tm.status AS membership_status
|
||||
@@ -177,6 +181,7 @@ function saas_current_user(?PDO $pdo = null): ?array
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
||||
@@ -264,6 +269,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
t.id AS tenant_id,
|
||||
t.slug,
|
||||
t.name,
|
||||
t.customer_type,
|
||||
t.status,
|
||||
t.timezone,
|
||||
t.locale,
|
||||
@@ -286,7 +292,10 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
ts.cash_contact,
|
||||
ts.bank_transfer_enabled,
|
||||
ts.bank_account_holder,
|
||||
ts.bank_iban
|
||||
ts.bank_iban,
|
||||
ts.brand_logo,
|
||||
ts.brand_color,
|
||||
ts.watermark_enabled
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
|
||||
WHERE t.id = ?
|
||||
@@ -309,6 +318,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
'tenant_id' => (int)$settings['tenant_id'],
|
||||
'slug' => (string)$settings['slug'],
|
||||
'name' => (string)$settings['name'],
|
||||
'customer_type' => (string)$settings['customer_type'],
|
||||
'status' => (string)$settings['status'],
|
||||
'timezone' => (string)$settings['timezone'],
|
||||
'locale' => (string)$settings['locale'],
|
||||
@@ -332,12 +342,16 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
'bank_transfer_enabled' => (int)$settings['bank_transfer_enabled'],
|
||||
'bank_account_holder' => (string)$settings['bank_account_holder'],
|
||||
'bank_iban' => (string)$settings['bank_iban'],
|
||||
'brand_logo' => (string)$settings['brand_logo'],
|
||||
'brand_color' => (string)$settings['brand_color'],
|
||||
'watermark_enabled' => (int)$settings['watermark_enabled'],
|
||||
];
|
||||
}
|
||||
|
||||
function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): array
|
||||
{
|
||||
$tenantName = trim((string)($input['tenant_name'] ?? ''));
|
||||
$customerType = (string)($input['customer_type'] ?? '');
|
||||
$timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin'));
|
||||
$locale = trim((string)($input['locale'] ?? 'de-DE'));
|
||||
$currencyCode = strtoupper(trim((string)($input['currency_code'] ?? 'EUR')));
|
||||
@@ -353,11 +367,20 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
|
||||
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
|
||||
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
|
||||
$brandColor = strtolower(trim((string)($input['brand_color'] ?? '')));
|
||||
|
||||
$errors = [];
|
||||
// Leer = Standarddesign. Sonst ein vollstaendiger Hex-Wert, der ohne
|
||||
// weitere Aufbereitung in das erzeugte Stylesheet geschrieben werden kann.
|
||||
if ($brandColor !== '' && !preg_match('/^#[0-9a-f]{6}$/', $brandColor)) {
|
||||
$errors[] = 'Die Akzentfarbe muss ein Hex-Wert wie #38761d sein.';
|
||||
}
|
||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
|
||||
}
|
||||
if (!in_array($customerType, ['business', 'consumer'], true)) {
|
||||
$errors[] = 'Bitte gib an, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.';
|
||||
}
|
||||
if (!in_array($timezone, DateTimeZone::listIdentifiers(), true)) {
|
||||
$errors[] = 'Die Zeitzone ist ungültig.';
|
||||
}
|
||||
@@ -415,15 +438,25 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
|
||||
$paymentReminderEnabled = !empty($input['payment_reminder_enabled']) ? 1 : 0;
|
||||
|
||||
// Der Hinweis auf die Kaffeeliste laesst sich abschalten - ausser im
|
||||
// kostenlosen Tarif. Die Pruefung steht hier und nicht nur im Formular,
|
||||
// damit auch ein nachgebauter POST das Kaestchen nicht leeren kann. Vor
|
||||
// der Transaktion, weil billing_fetch_or_init() eine fehlende Zeile
|
||||
// anlegt.
|
||||
$watermarkEnabled = !empty($input['watermark_enabled']) ? 1 : 0;
|
||||
if (billing_fetch_or_init($pdo, $tenantId)['plan_code'] === 'free') {
|
||||
$watermarkEnabled = 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE tenants
|
||||
SET name = ?, timezone = ?, locale = ?, currency_code = ?
|
||||
SET name = ?, customer_type = ?, timezone = ?, locale = ?, currency_code = ?
|
||||
WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$tenantName, $timezone, $locale, $currencyCode, $tenantId]);
|
||||
$stmt->execute([$tenantName, $customerType, $timezone, $locale, $currencyCode, $tenantId]);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenant_settings
|
||||
@@ -433,8 +466,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
pdf_watermark_text, pdf_footer_text,
|
||||
payment_reminder_enabled, payment_reminder_interval_days,
|
||||
cash_enabled, cash_contact, bank_transfer_enabled,
|
||||
bank_account_holder, bank_iban)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
bank_account_holder, bank_iban, brand_color, watermark_enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
mark_price_cents = VALUES(mark_price_cents),
|
||||
self_entry_enabled = VALUES(self_entry_enabled),
|
||||
@@ -453,7 +486,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
cash_contact = VALUES(cash_contact),
|
||||
bank_transfer_enabled = VALUES(bank_transfer_enabled),
|
||||
bank_account_holder = VALUES(bank_account_holder),
|
||||
bank_iban = VALUES(bank_iban)'
|
||||
bank_iban = VALUES(bank_iban),
|
||||
brand_color = VALUES(brand_color),
|
||||
watermark_enabled = VALUES(watermark_enabled)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
@@ -475,6 +510,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$bankTransferEnabled,
|
||||
$bankAccountHolder,
|
||||
$bankIban,
|
||||
$brandColor,
|
||||
$watermarkEnabled,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
@@ -528,7 +565,8 @@ function saas_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = '
|
||||
WHERE u.email_norm = ?
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?' . $tenantFilter . '
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . '
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
@@ -567,6 +605,7 @@ function saas_list_user_memberships(PDO $pdo, int $userId): array
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
@@ -604,6 +643,7 @@ function saas_identity_for_user_tenant(PDO $pdo, int $userId, int $tenantId): ?a
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
||||
@@ -845,6 +885,30 @@ function saas_change_password(
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestaetigt das Passwort eines bereits angemeldeten Kontos, ohne es zu
|
||||
* aendern. Gedacht fuer Aktionen, die eine bestehende Sitzung allein nicht
|
||||
* rechtfertigen soll - etwa das Abschalten des zweiten Faktors.
|
||||
*/
|
||||
function saas_password_matches(PDO $pdo, int $userId, string $password): bool
|
||||
{
|
||||
if ($password === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT password_hash, status FROM users WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user === false || (string)$user['status'] !== 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hash = (string)($user['password_hash'] ?? '');
|
||||
|
||||
return $hash !== '' && password_verify($password, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hat der Nutzer seine E-Mail-Adresse nachgewiesen? Wird fuer Aktionen
|
||||
* geprueft, die Mails an Dritte ausloesen oder fremde Konten anlegen -
|
||||
@@ -995,7 +1059,8 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?' . $tenantFilter . '
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . '
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
@@ -1180,6 +1245,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
$emailNorm = saas_email_norm($email);
|
||||
$password = (string)($input['password'] ?? '');
|
||||
$passwordConfirm = (string)($input['password_confirm'] ?? '');
|
||||
$customerType = (string)($input['customer_type'] ?? '');
|
||||
|
||||
$errors = [];
|
||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||
@@ -1200,6 +1266,18 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
if ($password !== $passwordConfirm) {
|
||||
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
|
||||
}
|
||||
if (!in_array($customerType, ['business', 'consumer'], true)) {
|
||||
$errors[] = 'Bitte gib an, ob du als Unternehmen oder als Verbraucher handelst.';
|
||||
}
|
||||
if (empty($input['accept_terms'])) {
|
||||
$errors[] = 'Bitte akzeptiere die AGB.';
|
||||
}
|
||||
if (empty($input['acknowledge_privacy'])) {
|
||||
$errors[] = 'Bitte bestätige, dass du die Datenschutzerklärung zur Kenntnis genommen hast.';
|
||||
}
|
||||
if (empty($input['accept_dpa'])) {
|
||||
$errors[] = 'Bitte vereinbare den AVV für den Fall, dass du Daten anderer Personen verwaltest.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
@@ -1239,10 +1317,10 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO tenants (slug, name, customer_type, status, timezone, locale, currency_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$tenantSlug, $tenantName, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
|
||||
$stmt->execute([$tenantSlug, $tenantName, $customerType, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
|
||||
$tenantId = (int)$pdo->lastInsertId();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
@@ -1285,6 +1363,11 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
);
|
||||
$stmt->execute([$tenantId, $userId, $displayName, $email, $emailNorm, 1]);
|
||||
|
||||
$acceptanceMetadata = ['customer_type' => $customerType];
|
||||
app_record_legal_acceptance($pdo, $tenantId, $userId, 'terms', 'registration', $acceptanceMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, $userId, 'privacy_notice', 'registration', $acceptanceMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, $userId, 'dpa', 'registration', $acceptanceMetadata);
|
||||
|
||||
$verificationToken = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440);
|
||||
|
||||
$pdo->commit();
|
||||
@@ -1309,6 +1392,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_slug' => $tenantSlug,
|
||||
'tenant_name' => $tenantName,
|
||||
'customer_type' => $customerType,
|
||||
'role' => 'owner',
|
||||
],
|
||||
'email_verification_token' => $verificationToken['token'],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/legal.php';
|
||||
|
||||
function saas_mail_transport(): string
|
||||
{
|
||||
@@ -172,6 +173,7 @@ function saas_log_outbound_email(
|
||||
?string $error,
|
||||
?int $createdByUserId
|
||||
): int {
|
||||
$pdo->exec('DELETE FROM outbound_emails WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO outbound_emails
|
||||
(tenant_id, participant_id, template, subject, status, sent_at, error, created_by_user_id)
|
||||
@@ -267,6 +269,71 @@ function saas_send_email_verification_mail(string $to, string $token): array
|
||||
return saas_send_mail($to, 'Kaffeeliste E-Mail bestätigen', $body);
|
||||
}
|
||||
|
||||
function saas_send_registration_contract_mail(string $to, string $tenantName, string $tenantSlug, string $customerType): array
|
||||
{
|
||||
$body = implode("\n", [
|
||||
'Vertragsbestätigung Kaffeeliste',
|
||||
'',
|
||||
'Kunde: ' . $tenantName,
|
||||
'Kundenkürzel: ' . $tenantSlug,
|
||||
'Kundentyp: ' . ($customerType === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
|
||||
'Tarif: Kostenlos, 0,00 € pro Monat, unbefristet, jederzeit kündbar',
|
||||
'Vertragsschluss: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
|
||||
'',
|
||||
'Nachfolgend erhältst du die bei Vertragsschluss geltenden Texte auf einem dauerhaften Datenträger.',
|
||||
'',
|
||||
'================ AGB ================',
|
||||
app_legal_document_text('terms'),
|
||||
'',
|
||||
'================ AVV ================',
|
||||
app_legal_document_text('dpa'),
|
||||
'',
|
||||
'========== WIDERRUFSBELEHRUNG ==========',
|
||||
app_legal_document_text('withdrawal_information'),
|
||||
]);
|
||||
|
||||
return saas_send_mail($to, 'Kaffeeliste Vertragsbestätigung – kostenloser Tarif', $body);
|
||||
}
|
||||
|
||||
function saas_send_paid_contract_confirmation(PDO $pdo, int $tenantId, string $planCode): array
|
||||
{
|
||||
require_once __DIR__ . '/billing.php';
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.name, t.slug, t.customer_type, u.email
|
||||
FROM tenants t
|
||||
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
|
||||
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
|
||||
WHERE t.id = ? ORDER BY tm.id LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$contract = $stmt->fetch();
|
||||
$plan = billing_plans()[$planCode] ?? null;
|
||||
if ($contract === false || $plan === null) {
|
||||
return ['ok' => false, 'transport' => saas_mail_transport(), 'error' => 'contract confirmation data missing'];
|
||||
}
|
||||
|
||||
$body = implode("\n", [
|
||||
'Vertragsbestätigung Kaffeeliste – kostenpflichtiger Tarif',
|
||||
'',
|
||||
'Kunde: ' . $contract['name'],
|
||||
'Kundenkürzel: ' . $contract['slug'],
|
||||
'Kundentyp: ' . ($contract['customer_type'] === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
|
||||
'Tarif: ' . $plan['label'],
|
||||
'Gesamtpreis: ' . number_format(((int)$plan['price_cents']) / 100, 2, ',', '') . ' € pro Monat',
|
||||
'Laufzeit: unbefristet; monatliche Abrechnung; keine längere Mindestlaufzeit',
|
||||
'Kündigung: jederzeit zum Ende der laufenden Abrechnungsperiode',
|
||||
'Bestätigung: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
|
||||
'',
|
||||
'================ AGB ================',
|
||||
app_legal_document_text('terms'),
|
||||
'',
|
||||
'========== WIDERRUFSBELEHRUNG ==========',
|
||||
app_legal_document_text('withdrawal_information'),
|
||||
]);
|
||||
|
||||
return saas_send_mail((string)$contract['email'], 'Kaffeeliste Vertragsbestätigung – ' . $plan['label'], $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Textkoerper der automatischen Zahlungserinnerung an ein Mitglied mit
|
||||
* offenem Betrag oberhalb der Warnschwelle des Mandanten.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/rate-limit.php';
|
||||
|
||||
/**
|
||||
* Schutz oeffentlicher Formulare gegen automatisierte Massenanlage.
|
||||
*
|
||||
* Hintergrund: Auf der Testumgebung liefen ab Juli 2026 taeglich 10-20
|
||||
* Registrierungen eines Bots, der zufaellige Namen mit *fremden*, echten
|
||||
* E-Mail-Adressen kombinierte. Der Server verschickte daraufhin
|
||||
* Vertrags- und Verifikationsmail an Leute, die nie etwas bestellt hatten -
|
||||
* Mail-Bombing ueber die eigene Domain, auf Kosten der Zustellbarkeit.
|
||||
*
|
||||
* Das IP-Rate-Limit half dagegen nicht: die Anfragen kamen ueber wechselnde
|
||||
* Rechenzentrums-IPs mit je einem Versuch. Deshalb hier bewusst Merkmale,
|
||||
* die nicht an der Herkunft haengen - und ohne Drittanbieter, der sonst in
|
||||
* den Registrierungspfad und die Datenschutzerklaerung muesste.
|
||||
*/
|
||||
|
||||
// Schneller als das kann niemand acht Felder und drei Checkboxen ausfuellen.
|
||||
const APP_SPAM_GUARD_MIN_SECONDS = 4;
|
||||
|
||||
// Notbremse ueber alle Registrierungen hinweg, unabhaengig von der IP.
|
||||
const APP_SPAM_GUARD_GLOBAL_MAX = 20;
|
||||
const APP_SPAM_GUARD_GLOBAL_WINDOW = 3600;
|
||||
|
||||
/**
|
||||
* Merkt sich, wann das Formular ausgeliefert wurde. Beim Rendern aufrufen,
|
||||
* nicht beim Verarbeiten.
|
||||
*/
|
||||
function app_spam_guard_mark_form_rendered(string $formKey): void
|
||||
{
|
||||
app_start_session();
|
||||
$_SESSION['spam_guard_rendered'][$formKey] = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kam die Antwort unmenschlich schnell? Fehlt der Zeitstempel ganz, wurde
|
||||
* das Formular in dieser Session nie ausgeliefert - das zaehlt ebenfalls als
|
||||
* Treffer. (Ein echter Nutzer kommt hier nicht an: ohne funktionierende
|
||||
* Session waere schon die CSRF-Pruefung fehlgeschlagen.)
|
||||
*/
|
||||
function app_spam_guard_submitted_too_fast(string $formKey, int $minSeconds = APP_SPAM_GUARD_MIN_SECONDS): bool
|
||||
{
|
||||
app_start_session();
|
||||
$renderedAt = $_SESSION['spam_guard_rendered'][$formKey] ?? null;
|
||||
if (!is_int($renderedAt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (time() - $renderedAt) < $minSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Honigtopf: fuer Menschen unsichtbar, fuer Formular-Bots ein Pflichtfeld.
|
||||
*
|
||||
* Die Positionierung steht bewusst inline und nicht in public.css - laedt das
|
||||
* Stylesheet einmal nicht, waere das Feld sonst sichtbar und ein echter
|
||||
* Nutzer koennte es ausfuellen. autocomplete="off" und der neutrale Name
|
||||
* halten Passwortmanager davon ab, es selbsttaetig zu befuellen;
|
||||
* tabindex="-1" und aria-hidden nehmen es aus Tastaturbedienung und
|
||||
* Screenreader heraus.
|
||||
*/
|
||||
function app_spam_guard_honeypot_field(): string
|
||||
{
|
||||
return '<div style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden" aria-hidden="true">'
|
||||
. '<label for="contact_reference">Dieses Feld bitte leer lassen</label>'
|
||||
. '<input type="text" name="contact_reference" id="contact_reference" value="" autocomplete="off" tabindex="-1">'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
function app_spam_guard_honeypot_tripped(): bool
|
||||
{
|
||||
return trim((string)($_POST['contact_reference'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt die Domain der Adresse ueberhaupt Mail an? Ohne MX-Eintrag gilt nach
|
||||
* RFC 5321 der A/AAAA-Record als Ziel, deshalb beide als Rueckfallebene.
|
||||
*
|
||||
* Bewusst fail-open: steht checkdnsrr() nicht zur Verfuegung, wird die
|
||||
* Adresse durchgelassen. Eine kaputte DNS-Aufloesung darf keine echten
|
||||
* Registrierungen blockieren.
|
||||
*/
|
||||
function app_spam_guard_email_domain_resolves(string $email): bool
|
||||
{
|
||||
if (!function_exists('checkdnsrr')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$at = strrpos($email, '@');
|
||||
if ($at === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$domain = rtrim(substr($email, $at + 1), '.');
|
||||
if ($domain === '' || !str_contains($domain, '.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return @checkdnsrr($domain, 'MX')
|
||||
|| @checkdnsrr($domain, 'A')
|
||||
|| @checkdnsrr($domain, 'AAAA');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Treffer wird protokolliert, aber dem Absender gegenueber nicht
|
||||
* benannt: er bekommt dieselbe generische Meldung wie bei einem
|
||||
* ausgereizten Rate-Limit. So verraet die Antwort nicht, welche Huerde
|
||||
* angeschlagen hat, und ein Bot kann sich nicht darauf einstellen.
|
||||
*/
|
||||
function app_spam_guard_log(string $formKey, string $reason): void
|
||||
{
|
||||
error_log(sprintf('Spam-Guard: %s abgewiesen (%s), IP %s', $formKey, $reason, app_client_ip()));
|
||||
}
|
||||
+44
-6
@@ -151,6 +151,12 @@ function stripe_create_checkout_session(string $priceId, string $customerEmail,
|
||||
// .deleted-Events liefern kein Checkout-Session-Objekt, aber die
|
||||
// Metadaten der Subscription, darueber loesen wir tenant_id auf.
|
||||
'subscription_data' => ['metadata' => $metadata],
|
||||
'locale' => 'de',
|
||||
'custom_text' => [
|
||||
'submit' => [
|
||||
'message' => 'Monatlich kündbar zum Ende der laufenden Abrechnungsperiode. Es gelten die vorab bestätigten AGB und die Widerrufsbelehrung.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if ($existingCustomerId !== null) {
|
||||
@@ -175,6 +181,17 @@ function stripe_create_billing_portal_session(string $customerId, string $return
|
||||
$result = stripe_request('POST', 'billing_portal/sessions', [
|
||||
'customer' => $customerId,
|
||||
'return_url' => $returnUrl,
|
||||
'locale' => 'de',
|
||||
// Als gezielter Stripe-Flow sind Navigation und andere Portalaktionen
|
||||
// ausgeblendet. Tarifwechsel und Kündigung bleiben damit in unseren
|
||||
// rechtlich dokumentierten Bestell- bzw. Kündigungsabläufen.
|
||||
'flow_data' => [
|
||||
'type' => 'payment_method_update',
|
||||
'after_completion' => [
|
||||
'type' => 'redirect',
|
||||
'redirect' => ['return_url' => $returnUrl],
|
||||
],
|
||||
],
|
||||
]);
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'url' => null, 'error' => $result['error']];
|
||||
@@ -247,9 +264,9 @@ function stripe_get_subscription(string $subscriptionId): array
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches an existing active subscription to a different Price in place
|
||||
* (upgrade or downgrade between paid tiers), instead of creating a brand
|
||||
* new Checkout Session. Stripe prorates the difference automatically.
|
||||
* Switches an existing active subscription to a different Price in place.
|
||||
* The billing cycle stays unchanged and no unclear mid-cycle debit/credit is
|
||||
* created; the new full monthly amount applies at the next renewal.
|
||||
* Also clears any pending cancel_at_period_end, since choosing a plan is
|
||||
* an explicit signal the tenant wants to keep the subscription running.
|
||||
*
|
||||
@@ -259,7 +276,7 @@ function stripe_update_subscription_price(string $subscriptionId, string $subscr
|
||||
{
|
||||
$result = stripe_request('POST', "subscriptions/{$subscriptionId}", [
|
||||
'items' => [['id' => $subscriptionItemId, 'price' => $newPriceId]],
|
||||
'proration_behavior' => 'create_prorations',
|
||||
'proration_behavior' => 'none',
|
||||
'cancel_at_period_end' => 'false',
|
||||
]);
|
||||
|
||||
@@ -271,8 +288,10 @@ function stripe_update_subscription_price(string $subscriptionId, string $subscr
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a subscription immediately (not at period end) - used when a
|
||||
* tenant downgrades all the way to the free plan from the admin UI.
|
||||
* Cancels a subscription immediately. This is reserved for exceptional
|
||||
* workflows such as a confirmed withdrawal; ordinary cancellations use
|
||||
* stripe_schedule_subscription_cancellation() so paid access remains until
|
||||
* the end of the already paid billing period.
|
||||
*
|
||||
* @return array{ok: bool, error: ?string}
|
||||
*/
|
||||
@@ -286,6 +305,25 @@ function stripe_cancel_subscription(string $subscriptionId): array
|
||||
return ['ok' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, current_period_end: ?string, error: ?string}
|
||||
*/
|
||||
function stripe_schedule_subscription_cancellation(string $subscriptionId): array
|
||||
{
|
||||
$result = stripe_request('POST', "subscriptions/{$subscriptionId}", [
|
||||
'cancel_at_period_end' => 'true',
|
||||
]);
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'current_period_end' => null, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
$periodEnd = isset($result['data']['current_period_end'])
|
||||
? date('Y-m-d H:i:s', (int)$result['data']['current_period_end'])
|
||||
: null;
|
||||
|
||||
return ['ok' => true, 'current_period_end' => $periodEnd, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, type: string, data: array}|null
|
||||
*/
|
||||
|
||||
+16
-4
@@ -28,14 +28,25 @@ function tenant_logo_path(string $filename): ?string
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dateiname-Praefix je Verwendungszweck. Das Wasserzeichen behaelt bewusst
|
||||
* seinen historischen Praefix "logo_", damit bereits hochgeladene Dateien
|
||||
* weiter gefunden werden.
|
||||
*/
|
||||
function tenant_logo_prefix(string $kind): string
|
||||
{
|
||||
return $kind === 'brand' ? 'brand_' : 'logo_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt ein hochgeladenes Logo (PNG oder JPEG) entgegen, prueft und speichert
|
||||
* es unter einem festen Namen pro Mandant.
|
||||
* es unter einem festen Namen pro Mandant und Verwendungszweck.
|
||||
*
|
||||
* @param array $file Eintrag aus $_FILES
|
||||
* @param string $kind 'watermark' (Ausdruck) oder 'brand' (App-Oberflaeche)
|
||||
* @return array{ok: bool, filename?: string, error?: string}
|
||||
*/
|
||||
function tenant_logo_store(int $tenantId, array $file): array
|
||||
function tenant_logo_store(int $tenantId, array $file, string $kind = 'watermark'): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht hochgeladen werden.'];
|
||||
@@ -63,14 +74,15 @@ function tenant_logo_store(int $tenantId, array $file): array
|
||||
}
|
||||
|
||||
// Alte Varianten (andere Endung) desselben Mandanten entfernen.
|
||||
$prefix = tenant_logo_prefix($kind);
|
||||
foreach (['png', 'jpg'] as $altExt) {
|
||||
$old = $dir . '/logo_' . $tenantId . '.' . $altExt;
|
||||
$old = $dir . '/' . $prefix . $tenantId . '.' . $altExt;
|
||||
if (is_file($old)) {
|
||||
@unlink($old);
|
||||
}
|
||||
}
|
||||
|
||||
$filename = 'logo_' . $tenantId . '.' . $ext;
|
||||
$filename = $prefix . $tenantId . '.' . $ext;
|
||||
if (!move_uploaded_file($tmp, $dir . '/' . $filename)) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht gespeichert werden.'];
|
||||
}
|
||||
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
|
||||
/**
|
||||
* Zweiter Faktor per TOTP (RFC 6238) - dieselben sechsstelligen Codes, die
|
||||
* Google Authenticator, Aegis, 1Password und Co. erzeugen.
|
||||
*
|
||||
* Bewusst selbst implementiert statt per Bibliothek: der Algorithmus ist ein
|
||||
* HMAC plus eine Truncation, PHP bringt beides mit, und ein zweiter Faktor
|
||||
* ist genau die Stelle, an der man keine ungeprueften Abhaengigkeiten haben
|
||||
* will. Der QR-Code entsteht aus dem ohnehin vorhandenen TCPDF, damit kein
|
||||
* externer Dienst den Klartext des Geheimnisses zu sehen bekommt.
|
||||
*/
|
||||
|
||||
const APP_TOTP_DIGITS = 6;
|
||||
const APP_TOTP_PERIOD = 30;
|
||||
|
||||
// Ein Schritt Toleranz in jede Richtung faengt Uhrenabweichungen zwischen
|
||||
// Telefon und Server ab, ohne das Zeitfenster nennenswert aufzuweiten.
|
||||
const APP_TOTP_WINDOW = 1;
|
||||
|
||||
const APP_TOTP_RECOVERY_CODE_COUNT = 10;
|
||||
|
||||
/** Nach dieser Zeit ist eine angefangene Anmeldung verfallen. */
|
||||
const APP_TOTP_CHALLENGE_TTL = 900;
|
||||
|
||||
function app_totp_base32_encode(string $binary): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$out = '';
|
||||
$buffer = 0;
|
||||
$bitsLeft = 0;
|
||||
|
||||
for ($i = 0, $len = strlen($binary); $i < $len; $i++) {
|
||||
$buffer = ($buffer << 8) | ord($binary[$i]);
|
||||
$bitsLeft += 8;
|
||||
while ($bitsLeft >= 5) {
|
||||
$bitsLeft -= 5;
|
||||
$out .= $alphabet[($buffer >> $bitsLeft) & 31];
|
||||
}
|
||||
}
|
||||
if ($bitsLeft > 0) {
|
||||
$out .= $alphabet[($buffer << (5 - $bitsLeft)) & 31];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
function app_totp_base32_decode(string $base32): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
// Leerzeichen und Bindestriche kommen vor, wenn jemand das Geheimnis von
|
||||
// Hand aus der Anzeige abtippt; Padding ist optional.
|
||||
$base32 = strtoupper(preg_replace('/[\s-]+/', '', $base32) ?? '');
|
||||
$base32 = rtrim($base32, '=');
|
||||
|
||||
$buffer = 0;
|
||||
$bitsLeft = 0;
|
||||
$out = '';
|
||||
|
||||
for ($i = 0, $len = strlen($base32); $i < $len; $i++) {
|
||||
$index = strpos($alphabet, $base32[$i]);
|
||||
if ($index === false) {
|
||||
return '';
|
||||
}
|
||||
$buffer = ($buffer << 5) | $index;
|
||||
$bitsLeft += 5;
|
||||
if ($bitsLeft >= 8) {
|
||||
$bitsLeft -= 8;
|
||||
$out .= chr(($buffer >> $bitsLeft) & 255);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 20 Zufallsbytes = 160 Bit, die von RFC 4226 empfohlene Schluessellaenge. */
|
||||
function app_totp_generate_secret(): string
|
||||
{
|
||||
return app_totp_base32_encode(random_bytes(20));
|
||||
}
|
||||
|
||||
function app_totp_code(string $secret, int $timeStep): string
|
||||
{
|
||||
$key = app_totp_base32_decode($secret);
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$counter = pack('J', $timeStep);
|
||||
$hash = hash_hmac('sha1', $counter, $key, true);
|
||||
|
||||
// Dynamic Truncation nach RFC 4226 Abschnitt 5.3.
|
||||
$offset = ord($hash[19]) & 0x0f;
|
||||
$value = ((ord($hash[$offset]) & 0x7f) << 24)
|
||||
| ((ord($hash[$offset + 1]) & 0xff) << 16)
|
||||
| ((ord($hash[$offset + 2]) & 0xff) << 8)
|
||||
| (ord($hash[$offset + 3]) & 0xff);
|
||||
|
||||
return str_pad((string)($value % (10 ** APP_TOTP_DIGITS)), APP_TOTP_DIGITS, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft den Code gegen das Toleranzfenster und liefert den getroffenen
|
||||
* Zeitschritt zurueck - den braucht der Aufrufer, um einen einmal benutzten
|
||||
* Code nicht ein zweites Mal zuzulassen.
|
||||
*/
|
||||
function app_totp_verify_code(string $secret, string $code, ?int $now = null): ?int
|
||||
{
|
||||
$code = preg_replace('/\s+/', '', $code) ?? '';
|
||||
if (preg_match('/^\d{' . APP_TOTP_DIGITS . '}$/', $code) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentStep = intdiv($now ?? time(), APP_TOTP_PERIOD);
|
||||
for ($offset = -APP_TOTP_WINDOW; $offset <= APP_TOTP_WINDOW; $offset++) {
|
||||
$step = $currentStep + $offset;
|
||||
$expected = app_totp_code($secret, $step);
|
||||
if ($expected !== '' && hash_equals($expected, $code)) {
|
||||
return $step;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* otpauth-URI nach der Key-Uri-Format-Spezifikation. Der Label-Teil traegt
|
||||
* Aussteller und Konto, damit in der App nicht zehn namenlose Eintraege
|
||||
* stehen.
|
||||
*/
|
||||
function app_totp_provisioning_uri(string $secret, string $accountName): string
|
||||
{
|
||||
$issuer = 'Kaffeeliste';
|
||||
|
||||
return 'otpauth://totp/'
|
||||
. rawurlencode($issuer) . ':' . rawurlencode($accountName)
|
||||
. '?' . http_build_query([
|
||||
'secret' => $secret,
|
||||
'issuer' => $issuer,
|
||||
'algorithm' => 'SHA1',
|
||||
'digits' => APP_TOTP_DIGITS,
|
||||
'period' => APP_TOTP_PERIOD,
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
/**
|
||||
* QR-Code als eingebettetes SVG. TCPDF liefert ein vollstaendiges Dokument
|
||||
* samt XML-Deklaration; fuer die Einbettung in eine HTML-Seite bleibt nur
|
||||
* das svg-Element uebrig.
|
||||
*/
|
||||
function app_totp_qr_svg(string $uri): string
|
||||
{
|
||||
require_once __DIR__ . '/../TCPDF/tcpdf_barcodes_2d.php';
|
||||
|
||||
$barcode = new TCPDF2DBarcode($uri, 'QRCODE,M');
|
||||
$svg = $barcode->getBarcodeSVGcode(4, 4, 'black');
|
||||
$start = strpos($svg, '<svg');
|
||||
|
||||
return $start === false ? '' : substr($svg, $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiederherstellungscodes fuer den Fall, dass das Telefon weg ist. Format
|
||||
* "abcd-efgh" aus einem Alphabet ohne verwechselbare Zeichen (kein 0/O,
|
||||
* kein 1/l), weil die Codes von Hand abgeschrieben und wieder eingetippt
|
||||
* werden.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function app_totp_generate_recovery_codes(int $count = APP_TOTP_RECOVERY_CODE_COUNT): array
|
||||
{
|
||||
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
$max = strlen($alphabet) - 1;
|
||||
$codes = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$code = '';
|
||||
for ($c = 0; $c < 8; $c++) {
|
||||
if ($c === 4) {
|
||||
$code .= '-';
|
||||
}
|
||||
$code .= $alphabet[random_int(0, $max)];
|
||||
}
|
||||
$codes[] = $code;
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
function app_totp_normalize_recovery_code(string $code): string
|
||||
{
|
||||
return strtolower(preg_replace('/[^A-Za-z0-9]/', '', $code) ?? '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistenz
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ist der zweite Faktor fuer dieses Konto scharf? Ein angefangenes, aber nie
|
||||
* bestaetigtes Geheimnis zaehlt nicht - sonst sperrte sich jemand aus, der
|
||||
* die Einrichtung auf halbem Weg abbricht.
|
||||
*/
|
||||
function app_totp_is_active(PDO $pdo, int $userId): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT totp_confirmed_at FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$confirmedAt = $stmt->fetchColumn();
|
||||
|
||||
return $confirmedAt !== false && $confirmedAt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{secret: ?string, confirmed_at: ?string, last_step: ?int}
|
||||
*/
|
||||
function app_totp_state(PDO $pdo, int $userId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT totp_secret, totp_confirmed_at, totp_last_step FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row === false) {
|
||||
return ['secret' => null, 'confirmed_at' => null, 'last_step' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'secret' => $row['totp_secret'] !== null ? (string)$row['totp_secret'] : null,
|
||||
'confirmed_at' => $row['totp_confirmed_at'] !== null ? (string)$row['totp_confirmed_at'] : null,
|
||||
'last_step' => $row['totp_last_step'] !== null ? (int)$row['totp_last_step'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein frisches, noch unbestaetigtes Geheimnis an. Ein bereits scharfer
|
||||
* zweiter Faktor wird dabei nicht angetastet - abschalten laeuft
|
||||
* ausschliesslich ueber app_totp_disable().
|
||||
*/
|
||||
function app_totp_begin_setup(PDO $pdo, int $userId): ?string
|
||||
{
|
||||
if (app_totp_is_active($pdo, $userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$secret = app_totp_generate_secret();
|
||||
$pdo->prepare('UPDATE users SET totp_secret = ?, totp_confirmed_at = NULL, totp_last_step = NULL WHERE id = ?')
|
||||
->execute([$secret, $userId]);
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet den zweiten Faktor scharf, nachdem ein gueltiger Code aus der App
|
||||
* kam, und gibt die Wiederherstellungscodes im Klartext zurueck - das ist
|
||||
* der einzige Moment, in dem sie sichtbar sind. Gespeichert werden nur ihre
|
||||
* Hashes.
|
||||
*
|
||||
* @return list<string>|null Klartext-Codes, oder null bei falschem Code.
|
||||
*/
|
||||
function app_totp_confirm_setup(PDO $pdo, int $userId, string $code): ?array
|
||||
{
|
||||
$state = app_totp_state($pdo, $userId);
|
||||
if ($state['secret'] === null || $state['confirmed_at'] !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$step = app_totp_verify_code($state['secret'], $code);
|
||||
if ($step === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$codes = app_totp_generate_recovery_codes();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('UPDATE users SET totp_confirmed_at = NOW(), totp_last_step = ? WHERE id = ?')
|
||||
->execute([$step, $userId]);
|
||||
$pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]);
|
||||
|
||||
$insert = $pdo->prepare('INSERT INTO totp_recovery_codes (user_id, code_hash) VALUES (?, ?)');
|
||||
foreach ($codes as $plain) {
|
||||
$insert->execute([$userId, password_hash(app_totp_normalize_recovery_code($plain), PASSWORD_DEFAULT)]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft einen Code aus der App. Ein bereits verwendeter Zeitschritt wird
|
||||
* abgelehnt: sonst liesse sich ein einmal abgefangener Code innerhalb seines
|
||||
* Gueltigkeitsfensters ein zweites Mal einloesen.
|
||||
*/
|
||||
function app_totp_verify_for_user(PDO $pdo, int $userId, string $code): bool
|
||||
{
|
||||
$state = app_totp_state($pdo, $userId);
|
||||
if ($state['secret'] === null || $state['confirmed_at'] === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$step = app_totp_verify_code($state['secret'], $code);
|
||||
if ($step === null) {
|
||||
return false;
|
||||
}
|
||||
if ($state['last_step'] !== null && $step <= $state['last_step']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE users SET totp_last_step = ? WHERE id = ?')->execute([$step, $userId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest einen Wiederherstellungscode ein. Jeder Code gilt genau einmal.
|
||||
*/
|
||||
function app_totp_consume_recovery_code(PDO $pdo, int $userId, string $code): bool
|
||||
{
|
||||
$normalized = app_totp_normalize_recovery_code($code);
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, code_hash FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
if (password_verify($normalized, (string)$row['code_hash'])) {
|
||||
$pdo->prepare('UPDATE totp_recovery_codes SET used_at = NOW() WHERE id = ? AND used_at IS NULL')
|
||||
->execute([(int)$row['id']]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function app_totp_unused_recovery_code_count(PDO $pdo, int $userId): int
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function app_totp_disable(PDO $pdo, int $userId): void
|
||||
{
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('UPDATE users SET totp_secret = NULL, totp_confirmed_at = NULL, totp_last_step = NULL WHERE id = ?')
|
||||
->execute([$userId]);
|
||||
$pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]);
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Angefangene Anmeldung (Passwort stimmt, zweiter Faktor fehlt noch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zwischenzustand nach richtigem Passwort. Bewusst nach demselben Muster wie
|
||||
* saas_start_pending_tenant_selection(): Session-ID erneuern und alle
|
||||
* Login-Schluessel loeschen, damit dieser Zustand fuer sich genommen
|
||||
* keinerlei Zugriff gewaehrt.
|
||||
*/
|
||||
function app_totp_start_challenge(int $userId): void
|
||||
{
|
||||
app_start_session();
|
||||
session_regenerate_id(true);
|
||||
|
||||
$_SESSION['totp_pending_user_id'] = $userId;
|
||||
$_SESSION['totp_pending_started_at'] = time();
|
||||
unset(
|
||||
$_SESSION['saas_user_id'],
|
||||
$_SESSION['saas_tenant_id'],
|
||||
$_SESSION['saas_role'],
|
||||
$_SESSION['saas_pending_user_id'],
|
||||
$_SESSION['saas_pending_started_at']
|
||||
);
|
||||
}
|
||||
|
||||
function app_totp_challenge_user_id(): ?int
|
||||
{
|
||||
app_start_session();
|
||||
|
||||
$userId = isset($_SESSION['totp_pending_user_id']) ? (int)$_SESSION['totp_pending_user_id'] : 0;
|
||||
$startedAt = isset($_SESSION['totp_pending_started_at']) ? (int)$_SESSION['totp_pending_started_at'] : 0;
|
||||
if ($userId <= 0 || $startedAt <= 0 || $startedAt < time() - APP_TOTP_CHALLENGE_TTL) {
|
||||
app_totp_clear_challenge();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
function app_totp_clear_challenge(): void
|
||||
{
|
||||
app_start_session();
|
||||
unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_started_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Muss dieses Konto zwingend einen zweiten Faktor haben? Standardmaessig
|
||||
* nein - der Schalter existiert, damit der Betreiber ihn erst umlegt,
|
||||
* nachdem er seine eigene Einrichtung erfolgreich getestet hat. Andernfalls
|
||||
* koennte ein Fehler im Einrichtungsweg den einzigen Platform-Admin vom
|
||||
* Back-Office aussperren.
|
||||
*/
|
||||
function app_totp_required_for_platform_admins(): bool
|
||||
{
|
||||
return app_env('APP_REQUIRE_2FA_FOR_ADMINS', '0') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Schliesst eine Anmeldung ab, deren zweiter Faktor bestaetigt ist. Die
|
||||
* Mandantenlage wird dabei frisch aus der Datenbank gelesen statt aus dem
|
||||
* Zwischenzustand uebernommen - zwischen Passwort und Code koennen Minuten
|
||||
* liegen, in denen sich Mitgliedschaften geaendert haben.
|
||||
*/
|
||||
function app_totp_finish_login(PDO $pdo, int $userId): void
|
||||
{
|
||||
app_totp_clear_challenge();
|
||||
$memberships = saas_list_user_memberships($pdo, $userId);
|
||||
|
||||
if (count($memberships) > 1) {
|
||||
saas_start_pending_tenant_selection($userId);
|
||||
header('Location: mandant-auswahl.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$identity = $memberships !== []
|
||||
? saas_identity_for_user_tenant($pdo, $userId, (int)$memberships[0]['tenant_id'])
|
||||
: null;
|
||||
|
||||
if ($identity === null) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
saas_session_login($identity);
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
@@ -129,6 +129,25 @@ div.hint-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Kopfzeile mit Mandanten-Logo (headerline.php)
|
||||
*
|
||||
* Das Logo soll erkennbar, aber kein Blickfang sein - die Kaffeeliste bleibt
|
||||
* eine Arbeitsoberflaeche. Feste Maximalhoehe, damit ein hochkantes oder sehr
|
||||
* grosses Logo nicht die halbe Seite einnimmt.
|
||||
* ------------------------------------------------------------------------ */
|
||||
.tenant-brand-line {
|
||||
margin-bottom: 1.5em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.tenant-brand-logo {
|
||||
display: block;
|
||||
max-height: 60px;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Hervorgehobene Tabellenzeile (aktuell gebuchter Tarif)
|
||||
*
|
||||
@@ -144,3 +163,69 @@ tr.tarif-aktuell > td {
|
||||
tr.tarif-aktuell > td:first-child {
|
||||
box-shadow: inset 5px 0 0 0 #4caf50;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Wasserzeichen im kostenlosen Tarif (app/plan-watermark.php)
|
||||
*
|
||||
* Fest unten rechts, damit es unabhaengig von der Seitenlaenge sichtbar
|
||||
* bleibt. Zurueckhaltend gehalten: die Kaffeeliste ist eine Arbeitsflaeche,
|
||||
* der Hinweis soll erkennbar sein, aber nichts verdecken - deshalb halb
|
||||
* transparent, erst beim Darueberfahren voll sichtbar.
|
||||
* ------------------------------------------------------------------------ */
|
||||
.plan-watermark {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 999px;
|
||||
bottom: 0.9rem;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||
color: #4a4a4a;
|
||||
display: inline-flex;
|
||||
font-size: 0.7rem;
|
||||
gap: 0.4em;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
opacity: 0.65;
|
||||
padding: 0.45em 0.8em;
|
||||
position: fixed;
|
||||
right: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
a.plan-watermark {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.plan-watermark:hover,
|
||||
.plan-watermark:focus {
|
||||
color: #4a4a4a;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.plan-watermark-logo {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Auf schmalen Bildschirmen bleibt nur das Logo - der Schriftzug wuerde dort
|
||||
ueber den Inhalt laufen. */
|
||||
@media screen and (max-width: 480px) {
|
||||
.plan-watermark-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Festgestellte Kaestchen (z. B. der Hinweis auf die Kaffeeliste im
|
||||
* kostenlosen Tarif)
|
||||
*
|
||||
* Das Theme zeichnet das Kaestchen ueber das folgende <label>; ein disabled
|
||||
* gesetztes Feld saehe sonst genauso aus wie ein bedienbares. Der gedimmte
|
||||
* Zustand macht sichtbar, dass hier nichts zu klicken ist - die Begruendung
|
||||
* steht als Hinweis daneben.
|
||||
* ------------------------------------------------------------------------ */
|
||||
input[type="checkbox"]:disabled + label {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
+142
-1
@@ -1,5 +1,4 @@
|
||||
@import url(fontawesome-all.min.css);
|
||||
@import url("https://fonts.googleapis.com/css?family=Open+Sans:400,600,400italic,600italic|Roboto+Slab:400,700");
|
||||
/*
|
||||
Editorial by HTML5 UP
|
||||
html5up.net | @ajlkn
|
||||
@@ -2293,3 +2292,145 @@ button,
|
||||
border-left: 5px solid #4caf50;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.hint-box.error {
|
||||
background: #fdeaea;
|
||||
border-left: 5px solid #c0392b;
|
||||
color: #641e16;
|
||||
}
|
||||
|
||||
input[type="submit"].alt,
|
||||
input[type="reset"].alt,
|
||||
input[type="button"].alt,
|
||||
button.alt,
|
||||
.button.alt {
|
||||
box-shadow: inset 0 0 0 2px #9fa3a6;
|
||||
color: #5f676d !important;
|
||||
}
|
||||
|
||||
.admin-summary-grid {
|
||||
display: grid;
|
||||
gap: 0.75em;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8.5em, 1fr));
|
||||
margin: 1.25em 0 1.75em;
|
||||
}
|
||||
|
||||
.admin-summary-grid > div {
|
||||
border: solid 1px rgba(210, 215, 217, 0.85);
|
||||
border-radius: 6px;
|
||||
padding: 0.9em 1em;
|
||||
}
|
||||
|
||||
.admin-summary-grid strong {
|
||||
color: #38761d;
|
||||
display: block;
|
||||
font-family: "Roboto Slab", serif;
|
||||
font-size: 1.45em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.admin-summary-grid span {
|
||||
color: #7f888f;
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
|
||||
.admin-filter-bar {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75em;
|
||||
margin: 1.25em 0;
|
||||
}
|
||||
|
||||
.admin-filter-bar label {
|
||||
font-size: 0.85em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-filter-bar input[type="search"],
|
||||
.admin-filter-bar select {
|
||||
min-width: 11em;
|
||||
}
|
||||
|
||||
.admin-table td,
|
||||
.admin-table th {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin: 0 0.25em 0.25em 0;
|
||||
padding: 0.2em 0.55em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: #fff4e5;
|
||||
color: #663c00;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: #fdeaea;
|
||||
color: #641e16;
|
||||
}
|
||||
|
||||
.status-badge.info {
|
||||
background: #e8f4ff;
|
||||
color: #0d3c61;
|
||||
}
|
||||
|
||||
.status-badge.muted {
|
||||
background: #edf0f2;
|
||||
color: #5f676d;
|
||||
}
|
||||
|
||||
.inline-admin-form {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
.inline-admin-form select {
|
||||
min-width: 13em;
|
||||
}
|
||||
|
||||
.compact-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.compact-actions li {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 736px) {
|
||||
.admin-filter-bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-filter-bar input[type="search"],
|
||||
.admin-filter-bar select,
|
||||
.admin-filter-bar button,
|
||||
.admin-filter-bar .button,
|
||||
.inline-admin-form select,
|
||||
.inline-admin-form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
.analytics-consent[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.analytics-consent {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
background: #ffffff;
|
||||
border-top: solid 1px rgba(210, 215, 217, 0.95);
|
||||
box-shadow: 0 -0.75rem 2rem rgba(61, 68, 73, 0.14);
|
||||
color: #3d4449;
|
||||
padding: 1.25rem 6vw;
|
||||
}
|
||||
|
||||
.analytics-consent__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.analytics-consent__copy {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.analytics-consent h2 {
|
||||
color: #3d4449;
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.analytics-consent p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.analytics-consent a {
|
||||
color: #2f6418;
|
||||
}
|
||||
|
||||
.analytics-consent__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.analytics-consent__actions button {
|
||||
appearance: none;
|
||||
background: #38761d;
|
||||
border: solid 2px #38761d;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: none;
|
||||
color: #ffffff !important;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
height: auto;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.25;
|
||||
padding: 0.8rem 1rem;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.analytics-consent__actions button:hover {
|
||||
background: #2f6418;
|
||||
border-color: #2f6418;
|
||||
}
|
||||
|
||||
.analytics-consent__actions [data-analytics-consent="denied"] {
|
||||
background: #ffffff;
|
||||
color: #2f6418 !important;
|
||||
}
|
||||
|
||||
.analytics-consent__actions [data-analytics-consent="denied"]:hover {
|
||||
background: #f2f7ef;
|
||||
border-color: #2f6418;
|
||||
color: #244f13 !important;
|
||||
}
|
||||
|
||||
.analytics-consent__actions button:focus-visible {
|
||||
outline: solid 3px #f0b429;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.footer-cookie-settings {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: dotted 1px;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
color: inherit !important;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
height: auto;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.footer-cookie-settings:hover {
|
||||
background: transparent;
|
||||
border-bottom-color: transparent;
|
||||
color: #38761d !important;
|
||||
}
|
||||
|
||||
.footer-cookie-settings:focus-visible {
|
||||
outline: solid 3px #f0b429;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.public-footer-links .footer-cookie-settings {
|
||||
color: #3d4449 !important;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 736px) {
|
||||
.analytics-consent {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.analytics-consent__inner,
|
||||
.analytics-consent__actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.analytics-consent__inner {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.analytics-consent__actions {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.analytics-consent__actions button {
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@@ -265,6 +265,15 @@ body.public-page {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ul.actions bringt von Haus aus keinen oberen Abstand mit (main.css setzt
|
||||
nur margin-left/padding-left), deshalb klebte die Button-Zeile direkt am
|
||||
letzten Eingabefeld. Bewusst auf .public-auth beschraenkt: das sind die
|
||||
Formularpanels (Login, Registrierung, Passwort), waehrend die CTA-Panels
|
||||
auf der Landingpage ihr enges Layout behalten. */
|
||||
.public-auth .public-panel .actions {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.public-steps .public-feature {
|
||||
position: relative;
|
||||
padding-top: 2.75rem;
|
||||
@@ -343,6 +352,14 @@ body.public-page {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.public-footer-links .legal-action-link {
|
||||
border: solid 1px #38761d;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
}
|
||||
|
||||
.public-cta ul.actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var configurationElement = document.getElementById('analytics-configuration');
|
||||
var banner = document.getElementById('analytics-consent');
|
||||
var settingsButtons = document.querySelectorAll('[data-analytics-settings]');
|
||||
|
||||
if (!configurationElement || !banner) {
|
||||
return;
|
||||
}
|
||||
|
||||
var configuration;
|
||||
try {
|
||||
configuration = JSON.parse(configurationElement.textContent || '');
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cookieName = configuration.consentCookie;
|
||||
var visitorCookieName = configuration.visitorCookie;
|
||||
var consentVersion = configuration.consentVersion;
|
||||
var pageViewSent = false;
|
||||
|
||||
function readCookie(name) {
|
||||
var cookies = document.cookie ? document.cookie.split(';') : [];
|
||||
for (var index = 0; index < cookies.length; index += 1) {
|
||||
var parts = cookies[index].trim().split('=');
|
||||
var currentName = decodeURIComponent(parts.shift() || '');
|
||||
if (currentName === name) {
|
||||
return decodeURIComponent(parts.join('='));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDecision() {
|
||||
var value = readCookie(cookieName);
|
||||
var expectedPrefix = consentVersion + ':';
|
||||
if (!value || value.indexOf(expectedPrefix) !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var decision = value.slice(expectedPrefix.length);
|
||||
return decision === 'granted' || decision === 'denied' ? decision : null;
|
||||
}
|
||||
|
||||
function cookieSuffix() {
|
||||
var suffix = '; Path=/; Max-Age=' + String(configuration.consentMaxAge) + '; SameSite=Lax';
|
||||
if (configuration.cookieDomain) {
|
||||
suffix += '; Domain=' + configuration.cookieDomain;
|
||||
}
|
||||
if (configuration.secureCookies || window.location.protocol === 'https:') {
|
||||
suffix += '; Secure';
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
function writeCookie(name, value) {
|
||||
document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) + cookieSuffix();
|
||||
}
|
||||
|
||||
function writeDecision(decision) {
|
||||
writeCookie(cookieName, consentVersion + ':' + decision);
|
||||
}
|
||||
|
||||
function randomVisitorId() {
|
||||
var bytes = new Uint8Array(8);
|
||||
window.crypto.getRandomValues(bytes);
|
||||
return Array.prototype.map.call(bytes, function (value) {
|
||||
return value.toString(16).padStart(2, '0');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function ensureVisitorId() {
|
||||
var visitorId = readCookie(visitorCookieName);
|
||||
if (!visitorId || !/^[0-9a-f]{16}$/.test(visitorId)) {
|
||||
visitorId = randomVisitorId();
|
||||
writeCookie(visitorCookieName, visitorId);
|
||||
}
|
||||
return visitorId;
|
||||
}
|
||||
|
||||
function URLWithoutParameters(value) {
|
||||
try {
|
||||
var url = new URL(value, window.location.href);
|
||||
return url.origin + url.pathname;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function sendPageView() {
|
||||
if (pageViewSent || readDecision() !== 'granted') {
|
||||
return;
|
||||
}
|
||||
pageViewSent = true;
|
||||
|
||||
var now = new Date();
|
||||
var payload = {
|
||||
visitorId: ensureVisitorId(),
|
||||
pageUrl: URLWithoutParameters(window.location.href),
|
||||
pageTitle: document.title || 'Kaffeeliste',
|
||||
referrerUrl: URLWithoutParameters(document.referrer),
|
||||
resolution: window.screen && window.screen.width && window.screen.height
|
||||
? String(window.screen.width) + 'x' + String(window.screen.height)
|
||||
: '',
|
||||
language: window.navigator.language || '',
|
||||
h: now.getHours(),
|
||||
m: now.getMinutes(),
|
||||
s: now.getSeconds()
|
||||
};
|
||||
|
||||
window.fetch(configuration.trackingEndpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true
|
||||
}).catch(function () {
|
||||
// Analysefehler duerfen die Website niemals beeintraechtigen.
|
||||
});
|
||||
}
|
||||
|
||||
function expireCookie(name, domain) {
|
||||
var value = encodeURIComponent(name) + '=; Path=/; Max-Age=0; SameSite=Lax';
|
||||
if (domain) {
|
||||
value += '; Domain=' + domain;
|
||||
}
|
||||
if (configuration.secureCookies || window.location.protocol === 'https:') {
|
||||
value += '; Secure';
|
||||
}
|
||||
document.cookie = value;
|
||||
}
|
||||
|
||||
function removeAnalysisCookies() {
|
||||
var cookies = document.cookie ? document.cookie.split(';') : [];
|
||||
cookies.forEach(function (cookie) {
|
||||
var name = decodeURIComponent(cookie.trim().split('=')[0] || '');
|
||||
if (name !== visitorCookieName && name.indexOf('_pk_') !== 0 && name.indexOf('mtm_') !== 0) {
|
||||
return;
|
||||
}
|
||||
expireCookie(name, '');
|
||||
if (configuration.cookieDomain) {
|
||||
expireCookie(name, configuration.cookieDomain);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeBanner() {
|
||||
banner.hidden = true;
|
||||
}
|
||||
|
||||
function choose(decision) {
|
||||
writeDecision(decision);
|
||||
closeBanner();
|
||||
if (decision === 'granted') {
|
||||
sendPageView();
|
||||
} else {
|
||||
removeAnalysisCookies();
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('kaffeeliste:analytics-consent', {
|
||||
detail: {analytics: decision}
|
||||
}));
|
||||
}
|
||||
|
||||
banner.querySelectorAll('[data-analytics-consent]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
choose(button.getAttribute('data-analytics-consent'));
|
||||
});
|
||||
});
|
||||
|
||||
settingsButtons.forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
banner.hidden = false;
|
||||
var firstButton = banner.querySelector('[data-analytics-consent="denied"]');
|
||||
if (firstButton) {
|
||||
firstButton.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var initialDecision = readDecision();
|
||||
if (initialDecision === 'granted') {
|
||||
sendPageView();
|
||||
} else if (initialDecision === 'denied') {
|
||||
removeAnalysisCookies();
|
||||
} else {
|
||||
banner.hidden = false;
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
app_record_public_page_view('dpa');
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>Auftragsverarbeitungsvertrag – Kaffeeliste</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="assets/css/main.css">
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
<section class="public-section public-legal">
|
||||
<nav class="public-nav" aria-label="Hauptnavigation">
|
||||
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
||||
<ul class="actions"><li><a href="login.php" class="button">Login</a></li></ul>
|
||||
</nav>
|
||||
<header class="major"><h1>Auftragsverarbeitungsvertrag</h1></header>
|
||||
<?php echo app_render_legal_document('dpa'); ?>
|
||||
<p><a href="legal/avv-<?php echo htmlspecialchars(APP_DPA_VERSION, ENT_QUOTES, 'UTF-8'); ?>.txt" download>AVV als Textdatei speichern</a></p>
|
||||
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
||||
</section>
|
||||
</div>
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
<script src="assets/js/util.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+55
-1
@@ -2,16 +2,35 @@
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = app_require_platform_admin($pdo);
|
||||
|
||||
$tenantId = filter_input(INPUT_GET, 'tenant_id', FILTER_VALIDATE_INT);
|
||||
// Die Mandanten-ID kommt beim Speichern der Funktions-Schalter aus dem
|
||||
// Formular, sonst aus der URL.
|
||||
$tenantId = filter_input(INPUT_POST, 'tenant_id', FILTER_VALIDATE_INT)
|
||||
?: filter_input(INPUT_GET, 'tenant_id', FILTER_VALIDATE_INT);
|
||||
if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
|
||||
http_response_code(400);
|
||||
exit('Ungültige Mandanten-ID.');
|
||||
}
|
||||
|
||||
$featureMeldung = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['aktion'] ?? '') === 'features_speichern') {
|
||||
app_require_csrf();
|
||||
|
||||
$geaendert = app_features_update($pdo, $tenantId, (array)($_POST['features'] ?? []));
|
||||
if ($geaendert !== []) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.features_updated', 'tenant', $tenantId, [
|
||||
'changed' => $geaendert,
|
||||
]);
|
||||
}
|
||||
$featureMeldung = $geaendert === []
|
||||
? 'Keine Änderung an den Funktionen.'
|
||||
: 'Die Funktionen wurden gespeichert.';
|
||||
}
|
||||
|
||||
$detail = app_backoffice_fetch_tenant_detail($pdo, $tenantId);
|
||||
if ($detail === null) {
|
||||
http_response_code(404);
|
||||
@@ -23,6 +42,8 @@ if ($detail === null) {
|
||||
// nachvollziehbar bleibt (siehe AVV-Transparenzpflicht).
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_viewed', 'tenant', $tenantId);
|
||||
|
||||
$tenantFeatures = app_features_for_tenant($pdo, $tenantId);
|
||||
|
||||
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
|
||||
|
||||
include 'header.php';
|
||||
@@ -51,6 +72,39 @@ include 'nav.php';
|
||||
<button type="submit">Datenexport dieses Mandanten herunterladen</button>
|
||||
</form>
|
||||
|
||||
<h3>Freigeschaltete Funktionen</h3>
|
||||
<p>Zentral vom Betreiber gesteuert: abgehakte Funktionen sind für diesen Mandanten
|
||||
gesperrt und verschwinden dort aus Menü und Seiten. Die Einstellungen des Kunden
|
||||
bleiben dabei erhalten.</p>
|
||||
|
||||
<?php if ($featureMeldung !== null): ?>
|
||||
<div class="hint-box success"><p><?php echo saas_html($featureMeldung); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="backoffice-mandant.php?tenant_id=<?php echo (int)$tenantId; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="features_speichern">
|
||||
<input type="hidden" name="tenant_id" value="<?php echo (int)$tenantId; ?>">
|
||||
<div class="row">
|
||||
<?php foreach (app_feature_catalog() as $featureKey => $feature): ?>
|
||||
<div class="col-6 col-12-small">
|
||||
<input type="checkbox"
|
||||
id="feature_<?php echo saas_html($featureKey); ?>"
|
||||
name="features[<?php echo saas_html($featureKey); ?>]"
|
||||
value="1"
|
||||
<?php echo ($tenantFeatures[$featureKey] ?? true) ? 'checked' : ''; ?>>
|
||||
<label for="feature_<?php echo saas_html($featureKey); ?>">
|
||||
<?php echo saas_html($feature['label']); ?>
|
||||
</label>
|
||||
<small><?php echo saas_html($feature['description']); ?></small>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit">Funktionen speichern</button></li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<h3>Mitglieder mit Zugang</h3>
|
||||
<table>
|
||||
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr>
|
||||
|
||||
@@ -3,12 +3,31 @@
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = app_require_platform_admin($pdo);
|
||||
|
||||
$tenants = app_backoffice_fetch_tenants($pdo);
|
||||
$billingPlans = billing_plans();
|
||||
$pageViewSummary = null;
|
||||
$pageViewError = false;
|
||||
try {
|
||||
$pageViewSummary = app_public_page_view_summary($pdo);
|
||||
} catch (Throwable $e) {
|
||||
$pageViewError = true;
|
||||
error_log('Back-Office-Seitenaufrufe konnten nicht geladen werden: ' . $e->getMessage());
|
||||
}
|
||||
$legalRequests = $pdo->query(
|
||||
"SELECT lr.id, lr.reference_code, lr.request_type, lr.requester_name,
|
||||
lr.requester_email, lr.contract_reference, lr.request_reason,
|
||||
lr.requested_end, lr.status, lr.received_at, t.name AS tenant_name
|
||||
FROM legal_requests lr
|
||||
LEFT JOIN tenants t ON t.id = lr.tenant_id
|
||||
WHERE lr.status <> 'processed'
|
||||
ORDER BY lr.received_at ASC
|
||||
LIMIT 100"
|
||||
)->fetchAll();
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
@@ -21,6 +40,62 @@ include 'nav.php';
|
||||
<p>Nur für Betreiber-Admins sichtbar: Übersicht aller Mandanten, unabhängig von deren eigener Mitgliedschaft.
|
||||
Jeder Zugriff wird im Audit-Log des jeweiligen Mandanten protokolliert.</p>
|
||||
|
||||
<h3>Öffentliche Seitenaufrufe</h3>
|
||||
<p><small>Anonyme Seitenaufrufe ohne Cookies, IP-Adressen oder Besucherkennungen. Wiederholte Aufrufe und automatisierte Abrufe können enthalten sein; die Zahlen sind keine eindeutigen Besucher.</small></p>
|
||||
<?php if ($pageViewError || $pageViewSummary === null): ?>
|
||||
<div class="hint-box warning"><p>Die Seitenaufrufe sind noch nicht verfügbar. Bitte Migration 0032 ausführen.</p></div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Heute</th><th>Letzte 30 Tage</th><th>Seit Beginn der Zählung</th></tr>
|
||||
<tr>
|
||||
<td><?php echo number_format($pageViewSummary['totals']['today'], 0, ',', '.'); ?></td>
|
||||
<td><?php echo number_format($pageViewSummary['totals']['last_30_days'], 0, ',', '.'); ?></td>
|
||||
<td><?php echo number_format($pageViewSummary['totals']['all_time'], 0, ',', '.'); ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if ($pageViewSummary['pages'] === []): ?>
|
||||
<div class="hint-box info"><p>Seit dem Start des Zählers wurden noch keine öffentlichen Seiten aufgerufen.</p></div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Seite</th><th>Heute</th><th>Letzte 30 Tage</th><th>Gesamt</th></tr>
|
||||
<?php foreach ($pageViewSummary['pages'] as $pageView): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($pageView['label']); ?></td>
|
||||
<td><?php echo number_format($pageView['today'], 0, ',', '.'); ?></td>
|
||||
<td><?php echo number_format($pageView['last_30_days'], 0, ',', '.'); ?></td>
|
||||
<td><?php echo number_format($pageView['all_time'], 0, ',', '.'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Offene Kündigungen und Widerrufe</h3>
|
||||
<?php if ($legalRequests === []): ?>
|
||||
<div class="hint-box success"><p>Keine offenen Rechtserklärungen.</p></div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Eingang</th><th>Art / Referenz</th><th>Kunde</th><th>Erklärung</th><th>Status</th><th></th></tr>
|
||||
<?php foreach ($legalRequests as $request): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($request['received_at']); ?></td>
|
||||
<td><?php echo $request['request_type'] === 'withdrawal' ? 'Widerruf' : 'Kündigung'; ?><br><small><?php echo saas_html($request['reference_code']); ?></small></td>
|
||||
<td><?php echo saas_html($request['requester_name']); ?><br><a href="mailto:<?php echo saas_html($request['requester_email']); ?>"><?php echo saas_html($request['requester_email']); ?></a><br><small><?php echo saas_html($request['tenant_name'] ?? $request['contract_reference']); ?></small></td>
|
||||
<td><?php echo $request['requested_end'] !== null ? 'Ende: ' . saas_html($request['requested_end']) . '<br>' : ''; ?><?php echo nl2br(saas_html($request['request_reason'] ?? '')); ?></td>
|
||||
<td><?php echo saas_html($request['status']); ?></td>
|
||||
<td>
|
||||
<form method="post" action="rechtserklaerung-bearbeiten.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="request_id" value="<?php echo (int)$request['id']; ?>">
|
||||
<button type="submit" class="button small">Als erledigt markieren</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
@@ -44,6 +119,7 @@ include 'nav.php';
|
||||
<br><small>benötigt: <?php echo saas_html($billingPlans[$tenantRequiredPlan]['label'] ?? $tenantRequiredPlan); ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo saas_html($tenant['created_at']); ?></td>
|
||||
<td><?php echo (int)$tenant['active_participant_count']; ?> / <?php echo (int)$tenant['participant_count']; ?></td>
|
||||
<td><?php echo saas_html(saas_format_money_cents((int)$tenant['balance_cents'])); ?> €</td>
|
||||
<td><a href="backoffice-mandant.php?tenant_id=<?php echo (int)$tenant['id']; ?>" class="button">Ansehen</a></td>
|
||||
|
||||
+199
-5
@@ -1,9 +1,12 @@
|
||||
<?php
|
||||
|
||||
ob_start();
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/imports.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -34,6 +37,12 @@ if (!$hasAccess) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'csv_import')) {
|
||||
echo app_feature_notice_html('csv_import');
|
||||
include "footer.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
function csv_upload_dir(): string
|
||||
{
|
||||
return APP_ROOT . '/var/uploads';
|
||||
@@ -103,22 +112,88 @@ function csv_status_label(string $status): string
|
||||
'unmatched' => 'Mitglied nicht gefunden',
|
||||
'invalid' => 'Ungültige Zeile',
|
||||
'imported' => 'Importiert',
|
||||
'ignored' => 'Ignoriert',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
function csv_status_class(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'matched', 'imported' => 'success',
|
||||
'duplicate' => 'warning',
|
||||
'unmatched', 'invalid' => 'error',
|
||||
'ignored' => 'muted',
|
||||
default => 'muted',
|
||||
};
|
||||
}
|
||||
|
||||
function csv_redirect_to_batch(?int $batchId = null): void
|
||||
{
|
||||
$target = 'csvupload.php';
|
||||
if ($batchId !== null && $batchId > 0) {
|
||||
$target .= '?batch_id=' . urlencode((string)$batchId);
|
||||
}
|
||||
header('Location: ' . $target);
|
||||
exit;
|
||||
}
|
||||
|
||||
function csv_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
|
||||
{
|
||||
$suggestedIds = [];
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$suggestedIds[(int)$suggestion['participant_id']] = true;
|
||||
}
|
||||
|
||||
$html = '<option value="">- Mitglied wählen -</option>';
|
||||
if ($suggestions !== []) {
|
||||
$html .= '<optgroup label="Vorschläge">';
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$id = (int)$suggestion['participant_id'];
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$suggestion['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
}
|
||||
|
||||
$html .= '<optgroup label="Alle aktiven Mitglieder">';
|
||||
foreach ($members as $member) {
|
||||
$id = (int)$member['participant_id'];
|
||||
if (isset($suggestedIds[$id])) {
|
||||
continue;
|
||||
}
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$member['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
$meldung = null;
|
||||
$fehler = null;
|
||||
$vorschauBatchId = null;
|
||||
$vorschauBatchId = isset($_GET['batch_id']) ? (int)$_GET['batch_id'] : null;
|
||||
|
||||
if (isset($_SESSION['flash_csvupload']) && is_array($_SESSION['flash_csvupload'])) {
|
||||
$flash = $_SESSION['flash_csvupload'];
|
||||
unset($_SESSION['flash_csvupload']);
|
||||
$meldung = isset($flash['meldung']) ? (string)$flash['meldung'] : null;
|
||||
$fehler = isset($flash['fehler']) ? (string)$flash['fehler'] : null;
|
||||
}
|
||||
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$aktion = $_POST['aktion'] ?? 'hochladen';
|
||||
$vorschauBatchId = (int)($_POST['batch_id'] ?? 0);
|
||||
|
||||
if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) {
|
||||
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
|
||||
|
||||
if ($uploadError !== null) {
|
||||
$fehler = $uploadError;
|
||||
$_SESSION['flash_csvupload'] = ['fehler' => $fehler];
|
||||
csv_redirect_to_batch();
|
||||
} else {
|
||||
try {
|
||||
$checksum = hash_file('sha256', $csvFile);
|
||||
@@ -160,6 +235,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
|
||||
$pdo->commit();
|
||||
$vorschauBatchId = $batchId;
|
||||
$meldung = 'CSV-Datei wurde eingelesen. Bitte die Vorschau prüfen.';
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
@@ -168,9 +244,43 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
} finally {
|
||||
@unlink($csvFile);
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
}
|
||||
} elseif ($aktion === 'zeile_zuordnen') {
|
||||
$rowId = (int)($_POST['row_id'] ?? 0);
|
||||
$participantId = (int)($_POST['participant_id'] ?? 0);
|
||||
if ($vorschauBatchId <= 0 || $rowId <= 0 || $participantId <= 0) {
|
||||
$fehler = 'Bitte eine Importzeile und ein Mitglied auswählen.';
|
||||
} else {
|
||||
$result = imports_assign_row($pdo, $tenantId, $vorschauBatchId, $rowId, $participantId);
|
||||
if ($result['ok']) {
|
||||
$meldung = 'Importzeile wurde zugeordnet.';
|
||||
} else {
|
||||
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht zugeordnet werden.';
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
} elseif ($aktion === 'zeile_ignorieren') {
|
||||
$rowId = (int)($_POST['row_id'] ?? 0);
|
||||
if ($vorschauBatchId <= 0 || $rowId <= 0) {
|
||||
$fehler = 'Bitte eine Importzeile auswählen.';
|
||||
} else {
|
||||
$result = imports_ignore_row($pdo, $tenantId, $vorschauBatchId, $rowId);
|
||||
if ($result['ok']) {
|
||||
$meldung = 'Importzeile wurde ignoriert.';
|
||||
} else {
|
||||
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht ignoriert werden.';
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
} elseif ($aktion === 'importieren') {
|
||||
$batchId = (int)($_POST['batch_id'] ?? 0);
|
||||
$batchId = $vorschauBatchId;
|
||||
try {
|
||||
$ergebnis = imports_commit_batch($pdo, $tenantId, $batchId);
|
||||
app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'csv_import.committed', 'payment_import_batch', $batchId, ['imported' => $ergebnis['imported']]);
|
||||
@@ -179,10 +289,22 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$fehler = $e->getMessage();
|
||||
$vorschauBatchId = $batchId;
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
}
|
||||
}
|
||||
|
||||
$vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null;
|
||||
$letzteBatches = imports_fetch_recent_batches($pdo, $tenantId, 8);
|
||||
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$vorschauStatusZaehler = [];
|
||||
if ($vorschau !== null) {
|
||||
foreach ($vorschau['rows'] as $row) {
|
||||
$status = (string)$row['status'];
|
||||
$vorschauStatusZaehler[$status] = ($vorschauStatusZaehler[$status] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -197,9 +319,15 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
||||
|
||||
<?php if ($vorschau !== null): ?>
|
||||
<h3>Vorschau: <?php echo saas_html($vorschau['batch']['original_filename']); ?></h3>
|
||||
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?></p>
|
||||
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?> ·
|
||||
<?php echo (int)($vorschauStatusZaehler['matched'] ?? 0); ?> bereit ·
|
||||
<?php echo (int)($vorschauStatusZaehler['unmatched'] ?? 0); ?> ohne Zuordnung ·
|
||||
<?php echo (int)($vorschauStatusZaehler['duplicate'] ?? 0); ?> Duplikatverdacht ·
|
||||
<?php echo (int)($vorschauStatusZaehler['invalid'] ?? 0); ?> ungültig
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr>
|
||||
<th>Zeile</th>
|
||||
<th>Name (CSV)</th>
|
||||
@@ -207,18 +335,58 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
||||
<th>Betrag</th>
|
||||
<th>Datum</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
<?php foreach ($vorschau['rows'] as $row): ?>
|
||||
<?php
|
||||
$rowStatus = (string)$row['status'];
|
||||
$suggestions = in_array($rowStatus, ['unmatched', 'duplicate'], true)
|
||||
? imports_suggest_participants($pdo, $tenantId, (string)$row['raw_name'], null, 4)
|
||||
: [];
|
||||
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo (int)$row['row_num']; ?></td>
|
||||
<td><?php echo saas_html($row['raw_name']); ?></td>
|
||||
<td><?php echo saas_html($row['display_name'] ?? '—'); ?></td>
|
||||
<td><?php echo saas_html(number_format(((int)$row['amount_cents']) / 100, 2, ',', '.')); ?> €</td>
|
||||
<td><?php echo saas_html($row['booked_at']); ?></td>
|
||||
<td><?php echo saas_html(csv_status_label($row['status'])); ?></td>
|
||||
<td><span class="status-badge <?php echo saas_html(csv_status_class($rowStatus)); ?>"><?php echo saas_html(csv_status_label($rowStatus)); ?></span></td>
|
||||
<td>
|
||||
<?php if ($vorschau['batch']['status'] === 'previewed' && in_array($rowStatus, ['unmatched', 'duplicate'], true)): ?>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form"<?php echo $rowStatus === 'duplicate' ? ' onsubmit="return confirm(\'Diese Zeile wurde als Duplikat erkannt. Trotzdem als neue Einzahlung buchen?\');"' : ''; ?>>
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_zuordnen">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<select name="participant_id" required>
|
||||
<?php echo csv_select_options($mitglieder, $suggestions, $selectedId); ?>
|
||||
</select>
|
||||
<button type="submit">Zuordnen</button>
|
||||
</form>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese Importzeile ignorieren?');">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_ignorieren">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
<?php elseif ($vorschau['batch']['status'] === 'previewed' && $rowStatus === 'invalid'): ?>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese ungültige Importzeile ignorieren?');">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_ignorieren">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($vorschau['batch']['status'] === 'previewed'): ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
@@ -231,6 +399,32 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
||||
<br>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($letzteBatches !== []): ?>
|
||||
<h3>Letzte Importvorschauen</h3>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr>
|
||||
<th>Datei</th>
|
||||
<th>Status</th>
|
||||
<th>Zeilen</th>
|
||||
<th>Bereit</th>
|
||||
<th>Offen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<?php foreach ($letzteBatches as $batch): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($batch['original_filename']); ?><br><small><?php echo saas_html($batch['created_at']); ?></small></td>
|
||||
<td><?php echo saas_html($batch['status']); ?></td>
|
||||
<td><?php echo (int)$batch['total_rows']; ?></td>
|
||||
<td><?php echo (int)$batch['matched_rows'] + (int)$batch['imported_rows']; ?></td>
|
||||
<td><?php echo (int)$batch['unresolved_rows']; ?></td>
|
||||
<td><a class="button small" href="csvupload.php?batch_id=<?php echo (int)$batch['id']; ?>">Öffnen</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form action="csvupload.php" method="post" enctype="multipart/form-data">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="hochladen">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Funktions-Schalter je Mandant, gepflegt vom Betreiber im Back-Office.
|
||||
--
|
||||
-- Bewusst getrennt von tenant_settings: dort stellt der Kunde selbst ein, wie
|
||||
-- seine Kaffeeliste arbeitet. Hier entscheidet der Betreiber, welche
|
||||
-- Funktionen dem Mandanten ueberhaupt zur Verfuegung stehen (z. B. zum
|
||||
-- Abschalten einer Funktion, die bei einem Kunden Probleme macht).
|
||||
--
|
||||
-- Ohne Zeile gilt eine Funktion als aktiv (siehe app/features.php); die
|
||||
-- Tabelle haelt also nur die Abweichungen vom Standard.
|
||||
CREATE TABLE IF NOT EXISTS tenant_features (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
feature_key VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_tenant_feature (tenant_id, feature_key),
|
||||
CONSTRAINT fk_tenant_features_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Eigenes Design je Mandant: Logo und Akzentfarbe fuer die App-Oberflaeche.
|
||||
--
|
||||
-- Getrennt von pdf_watermark_logo: das Wasserzeichen ist ein blasses
|
||||
-- Hintergrundbild fuer den Ausdruck, das Marken-Logo dagegen wird in der
|
||||
-- Kopfzeile jeder Seite scharf angezeigt. Leere Werte = Standarddesign.
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN brand_logo VARCHAR(255) NOT NULL DEFAULT '' AFTER bank_iban,
|
||||
ADD COLUMN brand_color VARCHAR(7) NOT NULL DEFAULT '' AFTER brand_logo;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Schalter fuer den Hinweis auf die Kaffeeliste (Logo unten rechts).
|
||||
--
|
||||
-- Standard ist "an": die Marke ist Teil der Oberflaeche, wer sie nicht
|
||||
-- moechte, blendet sie aus. Im kostenlosen Tarif bleibt sie unabhaengig von
|
||||
-- diesem Wert eingeblendet - erzwungen in saas_update_tenant_settings() und
|
||||
-- app/plan-watermark.php, damit auch ein manipulierter Formular-POST sie
|
||||
-- dort nicht abschalten kann.
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN watermark_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER brand_color;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Mailadresse des Zahlers aus der PayPal-Benachrichtigung.
|
||||
--
|
||||
-- Die automatische Zuordnung ging bisher nur ueber den Namen; die Adresse
|
||||
-- ist das deutlich verlaesslichere Merkmal (Mitglieder sind im Mandanten
|
||||
-- ueber tenant_id + email_norm eindeutig). Leer, wenn die Mail keine
|
||||
-- brauchbare Adresse enthaelt - dann bleibt es beim Namensvergleich.
|
||||
ALTER TABLE paypal_payments
|
||||
ADD COLUMN payer_email VARCHAR(255) NOT NULL DEFAULT '' AFTER payer_name;
|
||||
@@ -0,0 +1,63 @@
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN customer_type VARCHAR(20) NOT NULL DEFAULT 'unknown' AFTER name,
|
||||
ADD KEY idx_tenants_customer_type (customer_type);
|
||||
|
||||
ALTER TABLE rate_limit_attempts
|
||||
ADD KEY idx_rate_limit_attempts_created (created_at);
|
||||
|
||||
ALTER TABLE audit_log
|
||||
ADD KEY idx_audit_log_created (created_at);
|
||||
|
||||
ALTER TABLE outbound_emails
|
||||
ADD KEY idx_outbound_emails_created (created_at);
|
||||
|
||||
CREATE TABLE legal_acceptances (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NULL,
|
||||
user_id INT NULL,
|
||||
document_type VARCHAR(40) NOT NULL,
|
||||
document_version VARCHAR(20) NOT NULL,
|
||||
context VARCHAR(60) NOT NULL,
|
||||
ip VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(500) NULL,
|
||||
metadata_json TEXT NULL,
|
||||
accepted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_legal_acceptances_tenant (tenant_id, accepted_at),
|
||||
KEY idx_legal_acceptances_user (user_id, accepted_at),
|
||||
CONSTRAINT fk_legal_acceptances_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE SET NULL,
|
||||
CONSTRAINT fk_legal_acceptances_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE legal_requests (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
reference_code VARCHAR(40) NOT NULL,
|
||||
access_token_hash CHAR(64) NOT NULL,
|
||||
request_type VARCHAR(30) NOT NULL,
|
||||
tenant_id INT NULL,
|
||||
user_id INT NULL,
|
||||
requester_name VARCHAR(255) NOT NULL,
|
||||
requester_email VARCHAR(255) NOT NULL,
|
||||
contract_reference VARCHAR(255) NOT NULL,
|
||||
request_reason TEXT NULL,
|
||||
requested_end VARCHAR(40) NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'received',
|
||||
ip VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(500) NULL,
|
||||
metadata_json TEXT NULL,
|
||||
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at DATETIME NULL,
|
||||
UNIQUE KEY uq_legal_requests_reference (reference_code),
|
||||
UNIQUE KEY uq_legal_requests_access_token (access_token_hash),
|
||||
KEY idx_legal_requests_tenant (tenant_id, received_at),
|
||||
KEY idx_legal_requests_type_status (request_type, status, received_at),
|
||||
CONSTRAINT fk_legal_requests_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE SET NULL,
|
||||
CONSTRAINT fk_legal_requests_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN termination_requested_at DATETIME NULL AFTER customer_type,
|
||||
ADD COLUMN contract_ends_at DATETIME NULL AFTER termination_requested_at,
|
||||
ADD KEY idx_tenants_contract_end (contract_ends_at);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Zweiter Faktor per TOTP (RFC 6238).
|
||||
--
|
||||
-- totp_secret haelt das Base32-Geheimnis. Es steht schon vor der Bestaetigung
|
||||
-- in der Zeile, damit der eingegebene Code dagegen geprueft werden kann;
|
||||
-- scharf ist der zweite Faktor aber erst mit totp_confirmed_at. Wer die
|
||||
-- Einrichtung abbricht, sperrt sich damit nicht aus.
|
||||
--
|
||||
-- totp_last_step merkt sich den zuletzt eingeloesten Zeitschritt. Ohne das
|
||||
-- liesse sich ein abgefangener Code innerhalb seines Gueltigkeitsfensters
|
||||
-- ein zweites Mal verwenden.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN totp_secret VARCHAR(64) NULL AFTER password_hash,
|
||||
ADD COLUMN totp_confirmed_at DATETIME NULL AFTER totp_secret,
|
||||
ADD COLUMN totp_last_step BIGINT NULL AFTER totp_confirmed_at;
|
||||
|
||||
-- Wiederherstellungscodes fuer den Fall, dass das Telefon verloren geht.
|
||||
-- Gespeichert wird nur der Hash: im Klartext sind die Codes genau einmal
|
||||
-- sichtbar, direkt nach der Einrichtung.
|
||||
CREATE TABLE IF NOT EXISTS totp_recovery_codes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
code_hash VARCHAR(255) NOT NULL,
|
||||
used_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_totp_recovery_codes_user (user_id, used_at),
|
||||
CONSTRAINT fk_totp_recovery_codes_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Anonyme Reichweitenzaehlung fuer die oeffentlichen HTML-Seiten.
|
||||
-- Bewusst keine IP-Adresse, keine User-Agent-Daten, keine Session-/Cookie-ID
|
||||
-- und kein Zeitstempel pro Abruf: gespeichert werden nur Tageszähler je
|
||||
-- festem Seitenschluessel.
|
||||
CREATE TABLE public_page_views (
|
||||
view_date DATE NOT NULL,
|
||||
page_key VARCHAR(64) NOT NULL,
|
||||
view_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (page_key, view_date),
|
||||
KEY idx_public_page_views_date (view_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
+10
-3
@@ -3,6 +3,7 @@
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/data-export.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
@@ -12,6 +13,11 @@ if (!saas_can_manage_tenant_settings($user)) {
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, (int)$user['tenant_id'], 'data_export')) {
|
||||
http_response_code(403);
|
||||
exit('Der Datenexport ist für euren Zugang nicht freigeschaltet.');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
|
||||
@@ -38,9 +44,10 @@ include 'nav.php';
|
||||
<div class="content">
|
||||
<h2>Datenexport</h2>
|
||||
<p>Lädt alle für <?php echo saas_html($user['tenant_name']); ?> gespeicherten Daten als
|
||||
JSON-Datei herunter: Mitglieder, Zugänge und Rollen, alle Buchungen, Hinweise,
|
||||
CSV-Importe, Mail-Versandlog und das Admin-Protokoll. Passwörter werden nicht
|
||||
exportiert.</p>
|
||||
JSON-Datei herunter: Einstellungen und Logos, Mitglieder, Zugänge und Rollen,
|
||||
alle Buchungen, PayPal-Zuordnungen, Hinweise und FAQ, CSV-Importe, Tarif- und
|
||||
Funktionsstatus, Rechtstextnachweise, Mail-Versandlog und Admin-Protokoll.
|
||||
Passwörter, Token-Hashes und Stripe-Schlüssel werden nicht exportiert.</p>
|
||||
|
||||
<form method="post" action="datenexport.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
|
||||
+16
-108
@@ -1,125 +1,33 @@
|
||||
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
|
||||
<?php
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
app_record_public_page_view('privacy');
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>Datenschutzerklärung – Kaffeeliste</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="assets/css/main.css">
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
<section class="public-section public-legal">
|
||||
<nav class="public-nav" aria-label="Hauptnavigation">
|
||||
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
||||
<ul class="actions">
|
||||
<li><a href="login.php" class="button">Login</a></li>
|
||||
</ul>
|
||||
<ul class="actions"><li><a href="login.php" class="button">Login</a></li></ul>
|
||||
</nav>
|
||||
|
||||
<header class="major">
|
||||
<h1>Datenschutzerklärung</h1>
|
||||
</header>
|
||||
|
||||
<div class="hint-box error">
|
||||
<p><b>Hinweis: Dies ist ein Entwurf</b>, der die tatsächliche
|
||||
technische Datenverarbeitung dieser App wiedergibt, aber vor
|
||||
Produktivbetrieb rechtlich geprüft werden sollte.</p>
|
||||
</div>
|
||||
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
<p>
|
||||
Clemens Creutzburg<br>
|
||||
In den Sieben Stücken 9d<br>
|
||||
30655 Hannover<br>
|
||||
E-Mail: <a href="mailto:info@ctb-it.de">info@ctb-it.de</a>
|
||||
</p>
|
||||
<p>Ein Datenschutzbeauftragter ist gesetzlich nicht vorgeschrieben und
|
||||
daher nicht bestellt.</p>
|
||||
|
||||
<h2>2. Zwei Rollen: Verantwortlicher und Auftragsverarbeiter</h2>
|
||||
<p>Registrierst du ein Kundenkonto, sind wir für die Daten deines
|
||||
Kundenkontos selbst (Name, E-Mail, Kundenname) Verantwortlicher im Sinne
|
||||
der DSGVO. Für die Daten der Teilnehmer, die du innerhalb deines
|
||||
Mandanten anlegst (z. B. Kolleginnen und Kollegen), verarbeiten wir die
|
||||
Daten dagegen ausschließlich in deinem Auftrag und nach deiner Weisung –
|
||||
du bist insoweit selbst Verantwortlicher, wir sind Auftragsverarbeiter.
|
||||
Die Einzelheiten regelt ein separater Auftragsverarbeitungsvertrag (AVV).</p>
|
||||
|
||||
<h2>3. Welche Daten wir verarbeiten</h2>
|
||||
<p><b>Kundenkonto (Login):</b> E-Mail-Adresse, Anzeigename,
|
||||
Passwort (nur als Hash gespeichert, im Klartext nicht einsehbar),
|
||||
Zeitpunkt der letzten Anmeldung, Bestätigungsstatus der E-Mail-Adresse.</p>
|
||||
<p><b>Teilnehmer innerhalb eines Mandanten:</b> Name, E-Mail-Adresse,
|
||||
optional ein PayPal-Anzeigename, sowie die Buchungshistorie (Striche,
|
||||
Einzahlungen, Salden).</p>
|
||||
<p><b>Technische Daten:</b> ein Sitzungs-Cookie
|
||||
(<code>kaffeeliste_session</code>, ausschließlich technisch notwendig,
|
||||
kein Tracking, keine Werbe- oder Analyse-Cookies), sowie zur Abwehr von
|
||||
Missbrauch die IP-Adresse bei Login-, Registrierungs- und
|
||||
Passwort-Reset-Versuchen (automatisiert nach kurzer Zeit gelöscht) und in
|
||||
Protokollen sicherheitsrelevanter Admin-Aktionen.</p>
|
||||
<p>Diese App bindet keine externen Analyse- oder Tracking-Dienste
|
||||
(z. B. Google Analytics) und keine Werbenetzwerke ein.</p>
|
||||
|
||||
<h2>4. Zwecke und Rechtsgrundlagen</h2>
|
||||
<p>Die Verarbeitung erfolgt zur Erfüllung des Nutzungsvertrags über die
|
||||
Kaffeeliste-App (Art. 6 Abs. 1 lit. b DSGVO), zur Wahrung berechtigter
|
||||
Interessen an einem sicheren und missbrauchsfreien Betrieb (Art. 6 Abs. 1
|
||||
lit. f DSGVO, z. B. Rate-Limiting, Protokollierung sicherheitsrelevanter
|
||||
Aktionen) sowie, soweit einschlägig, zur Erfüllung rechtlicher
|
||||
Aufbewahrungspflichten (Art. 6 Abs. 1 lit. c DSGVO, siehe Ziffer 6).</p>
|
||||
|
||||
<h2>5. E-Mail-Versand</h2>
|
||||
<p>Wir versenden E-Mails für Passwort-Zurücksetzung, E-Mail-Bestätigung,
|
||||
Einladungen sowie optionale Stand-Benachrichtigungen. Der Versand erfolgt
|
||||
über den Mailserver unseres Hosting-Anbieters. Gespeichert werden dabei
|
||||
Betreff, Versandstatus und Zeitpunkt, nicht der vollständige Mailinhalt.</p>
|
||||
|
||||
<h2>6. Speicherdauer</h2>
|
||||
<p>Kontodaten werden gespeichert, solange das Kundenkonto besteht.
|
||||
Buchungsdaten (Striche, Einzahlungen) sind kassenrelevante Unterlagen und
|
||||
unterliegen den gesetzlichen Aufbewahrungspflichten nach § 257 HGB
|
||||
beziehungsweise § 147 AO (in der Regel sechs bis zehn Jahre). Entfernst du
|
||||
einen Teilnehmer, werden Name und E-Mail-Adresse deshalb anonymisiert statt
|
||||
gelöscht, während die Buchungshistorie für die Kassenführung erhalten
|
||||
bleibt. Eine vollständige Löschung deines gesamten Mandanten einschließlich
|
||||
aller Buchungen kannst du jederzeit selbst über die Mandanten-Löschung
|
||||
auslösen.</p>
|
||||
|
||||
<h2>7. Deine Rechte</h2>
|
||||
<p>Du hast das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung
|
||||
(Art. 16 DSGVO), Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung
|
||||
(Art. 18 DSGVO), Datenübertragbarkeit (Art. 20 DSGVO) sowie Widerspruch
|
||||
gegen die Verarbeitung (Art. 21 DSGVO). Als Mandanten-Inhaber oder
|
||||
-Administrator kannst du einen vollständigen Export deiner Daten
|
||||
jederzeit selbst über die Funktion „Datenexport" herunterladen. Für
|
||||
Anfragen wende dich an die oben genannte Kontaktadresse. Dir steht zudem
|
||||
ein Beschwerderecht bei einer Datenschutzaufsichtsbehörde zu, insbesondere
|
||||
bei der für unseren Sitz zuständigen Behörde (Landesbeauftragte für den
|
||||
Datenschutz Niedersachsen).</p>
|
||||
|
||||
<h2>8. Sicherheit</h2>
|
||||
<p>Wir setzen unter anderem folgende technische Maßnahmen ein: verschlüsselte
|
||||
Übertragung (TLS), gehashte Passwörter, serverseitige Zugriffskontrolle je
|
||||
Mandant, Schutz vor automatisierten Anmeldeversuchen (Rate-Limiting),
|
||||
Schutz vor Cross-Site-Request-Forgery sowie ein Protokoll
|
||||
sicherheitsrelevanter Admin-Aktionen.</p>
|
||||
|
||||
<h2>9. Hosting</h2>
|
||||
<p>Diese App wird auf Servern innerhalb der Europäischen Union betrieben.</p>
|
||||
|
||||
<header class="major"><h1>Datenschutzerklärung</h1></header>
|
||||
<?php echo app_render_legal_document('privacy_notice'); ?>
|
||||
<p><a href="legal/datenschutz-<?php echo htmlspecialchars(APP_PRIVACY_VERSION, ENT_QUOTES, 'UTF-8'); ?>.txt" download>Datenschutzerklärung als Textdatei speichern</a></p>
|
||||
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="public-footer-links">
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
</footer>
|
||||
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
@@ -29,8 +29,13 @@ dort ist ab zwei Mitgliedschaften auch „Mandant wechseln" ergänzt.
|
||||
|
||||
- ✅ Adaptives Layout nach Mitgliederanzahl (Zeilenhöhe 16–40px je nach
|
||||
Mitgliederzahl).
|
||||
- ✅ Vorder-/Rückseite bzw. mehrere Seiten erst ab 50 Mitgliedern
|
||||
(`EXPORT_MULTI_PAGE_THRESHOLD`), darunter eine einseitige Liste.
|
||||
- ✅ Vorder-/Rückseite bzw. mehrere Seiten erst bei **mehr als** 50
|
||||
Mitgliedern, bis einschließlich 50 eine einseitige Liste. Die Schwelle
|
||||
liegt seit 2026-08-17 als `LEDGER_SINGLE_PAGE_MAX_MEMBERS` /
|
||||
`ledger_uses_two_sided_sheet()` in `app/ledger.php` und gilt damit
|
||||
einheitlich für den Ausdruck **und** die Vorder-/Rückseiten-Auswahl beim
|
||||
Erfassen (`stricheintragen.php`, `einzahlung.php`) – dort erschienen die
|
||||
Buttons vorher auch bei Kleinstteams, für die es nur ein Blatt gibt.
|
||||
- ✅ Admin-Einstellung „Freie Zeilen für neue Mitglieder"
|
||||
(`tenant_settings.pdf_show_empty_rows`, Formular in
|
||||
`mandant-einstellungen.php`).
|
||||
|
||||
+167
-5
@@ -54,15 +54,93 @@ demselben Grund wie beim Setzen).
|
||||
|
||||
- `backoffice.php`: Übersicht aller Mandanten mit Kürzel, Status,
|
||||
Erstelldatum, Teilnehmerzahl (aktiv/gesamt) und Saldensumme.
|
||||
- `backoffice-mandant.php?tenant_id=X`: reine Leseansicht eines einzelnen
|
||||
Mandanten – Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen,
|
||||
letzte 20 Admin-Aktionen. Absichtlich **kein** Schreibzugriff auf
|
||||
Mandantendaten von hier aus, um das Risiko einer versehentlichen
|
||||
Fremdänderung auszuschließen.
|
||||
- `backoffice-mandant.php?tenant_id=X`: Ansicht eines einzelnen Mandanten
|
||||
– Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen, letzte 20
|
||||
Admin-Aktionen, dazu die Funktions-Schalter (siehe unten). Absichtlich
|
||||
**kein** Schreibzugriff auf die eigentlichen Mandantendaten (Buchungen,
|
||||
Mitglieder, Einstellungen des Kunden) von hier aus, um das Risiko einer
|
||||
versehentlichen Fremdänderung auszuschließen.
|
||||
- `backoffice-export.php`: nutzt dieselbe `app_export_tenant_data()`-
|
||||
Funktion wie der Selbstbedienungs-Export, aber ausgelöst durch den
|
||||
Platform-Admin für einen beliebigen Mandanten.
|
||||
|
||||
## Funktions-Schalter je Mandant
|
||||
|
||||
Stand: 2026-08-17. Zentralstelle des Betreibers, um einzelne Funktionen
|
||||
pro Mandant freizuschalten oder zu sperren – gepflegt in
|
||||
`backoffice-mandant.php`, umgesetzt in `app/features.php` und der Tabelle
|
||||
`tenant_features` (Migration `0025_tenant_features.sql`).
|
||||
|
||||
Schaltbar sind: FAQ-Seite, Striche selbst eintragen, Kaffeeliste als PDF,
|
||||
PayPal-Zahlungseingang, CSV-Import, Info-Mails und Erinnerungen,
|
||||
Jahresabschluss, Datenexport, eigenes Design.
|
||||
|
||||
Design-Entscheidungen:
|
||||
|
||||
- **Getrennt von `tenant_settings`.** Dort stellt der Kunde ein, *wie*
|
||||
eine Funktion arbeitet; hier entscheidet der Betreiber, ob sie ihm
|
||||
überhaupt zur Verfügung steht. Beide Prüfungen stehen nebeneinander,
|
||||
z. B. beim Selbsteintrag: `self_entry_enabled` (Kunde) **und**
|
||||
Feature `self_entry` (Betreiber).
|
||||
- **Ohne Zeile = freigeschaltet.** `tenant_features` hält nur die
|
||||
Abweichungen vom Standard; ein neuer Mandant bekommt automatisch den
|
||||
vollen Funktionsumfang, ohne dass der Betreiber etwas einschalten muss.
|
||||
Fehlt die Tabelle (Migration noch nicht ausgerollt), bleibt ebenfalls
|
||||
alles freigeschaltet – eine ausstehende Migration darf keine Funktion
|
||||
sperren.
|
||||
- **Gesperrt heißt unsichtbar.** Die betroffenen Menüpunkte und
|
||||
Schaltflächen verschwinden (`footer.php`, `kaffeeliste.php`,
|
||||
`einzahlung.php`, `konto.php`); ein direkter Aufruf der Seite endet mit
|
||||
einem Hinweis statt mit einem Fehler. Die POST-Verarbeitung der
|
||||
betroffenen Seiten hängt an derselben Prüfung, eine gesperrte Funktion
|
||||
lässt sich also auch nicht per Formular-POST auslösen.
|
||||
- **Abgeschaltet vom Kunden heißt ebenfalls unsichtbar.** Für Funktionen
|
||||
mit eigenem Schalter in den Mandant-Einstellungen zieht `footer.php`
|
||||
(und die Anleitung) `app_feature_available()` heran: erst die
|
||||
Freischaltung durch den Betreiber, dann der Schalter des Kunden. Die
|
||||
Zuordnung steht in `app_feature_tenant_switch()` – aktuell
|
||||
`paypal_inbox` → `paypal_enabled` und `self_entry` →
|
||||
`self_entry_enabled`. Wer PayPal nicht als Zahlungsweg anbietet, sieht
|
||||
den Menüpunkt „PayPal-Zahlungen" also gar nicht erst; beim direkten
|
||||
Aufruf erscheint statt des Betreiber-Hinweises
|
||||
`app_feature_tenant_notice_html()` mit dem Weg zurück in die
|
||||
Mandant-Einstellungen.
|
||||
- **Ausnahme: offene Warteschlange.** Schaltet ein Mandant PayPal ab,
|
||||
während noch nicht zugeordnete Zahlungen liegen, wären diese weder über
|
||||
Menü noch über URL erreichbar. `paypal_inbox_accessible()`
|
||||
(`app/paypal-inbox.php`) hält Menüpunkt und Seite deshalb offen, solange
|
||||
`paypal_count_unmatched()` etwas findet – mit einem Hinweis auf der
|
||||
Seite. Ist die Warteschlange abgearbeitet, schließt sie sich von selbst.
|
||||
Eine Sperre durch den Betreiber sticht auch diese Ausnahme.
|
||||
- Jede Änderung landet als `backoffice.features_updated` im Audit-Log des
|
||||
betroffenen Mandanten – wie jeder andere Back-Office-Zugriff auch.
|
||||
|
||||
Das Back-Office ist außerdem jetzt für Platform-Admins im Menü verlinkt
|
||||
(Gruppe „Betreiber" in `footer.php`); vorher war es nur über die direkte
|
||||
URL erreichbar.
|
||||
|
||||
## Eigenes Design je Mandant
|
||||
|
||||
Stand: 2026-08-17, hängt am Feature `branding`. Der Kunde hinterlegt in
|
||||
`mandant-einstellungen.php` eine Akzentfarbe und ein Logo:
|
||||
|
||||
- Die Farbe (`tenant_settings.brand_color`, validiert als `#rrggbb`) wird
|
||||
in `header.php` als kleines Stylesheet eingebettet
|
||||
(`app/branding.php`), das die Akzentfarbe des Templates überschreibt.
|
||||
Eine eigene CSS-Datei je Mandant wäre ein zusätzlicher Request für
|
||||
wenige Zeilen.
|
||||
- Das Logo (`tenant_settings.brand_logo`) liegt wie das PDF-Wasserzeichen
|
||||
in `var/tenant_logos` und ist damit **nicht** direkt per URL abrufbar –
|
||||
sonst wäre jedes Kundenlogo unter einer ratbaren Adresse öffentlich.
|
||||
Ausgeliefert wird es über `tenant-logo-anzeigen.php`, das ausschließlich
|
||||
das Logo des Mandanten des angemeldeten Nutzers ausgibt.
|
||||
- Wasserzeichen (Ausdruck) und Marken-Logo (Oberfläche) sind getrennte
|
||||
Dateien mit eigenem Namenspräfix (`logo_` bzw. `brand_`).
|
||||
- Sperrt der Betreiber `branding`, verschwindet der Abschnitt aus den
|
||||
Einstellungen und die Oberfläche fällt aufs Standarddesign zurück. Die
|
||||
gespeicherte Farbe bleibt erhalten und gilt nach einer erneuten
|
||||
Freischaltung wieder.
|
||||
|
||||
## Verhältnis zum Selbstbedienungs-Export (`datenexport.php`)
|
||||
|
||||
Bewusste Entscheidung: Der bestehende Selbstbedienungs-Export für
|
||||
@@ -108,3 +186,87 @@ weiterhin ausschließlich seinen eigenen Mandanten. Ein zweiter,
|
||||
eingeloggter, aber nicht privilegierter Testnutzer bekommt auf
|
||||
`backoffice.php` korrekt `403 Forbidden`. Alle Testdaten anschließend
|
||||
vollständig entfernt.
|
||||
|
||||
## Eingehende PayPal-Mails bei abgeschalteter Funktion
|
||||
|
||||
Stand: 2026-08-21. Die Mail-Verarbeitung
|
||||
(`scripts/fetch-paypal-payments.php` → `paypal_reconcile()`) prüft
|
||||
dieselben zwei Ebenen wie das Menü. Ist PayPal für den Mandanten
|
||||
abgeschaltet — vom Betreiber (`paypal_inbox`) oder vom Mandanten selbst
|
||||
(`paypal_enabled`) —, wird eine eingehende Zahlung **geparkt**: sie wird
|
||||
gespeichert (Status `unmatched`, Ergebnis `parked`), aber nicht
|
||||
automatisch gutgeschrieben.
|
||||
|
||||
- **Warum nicht wegwerfen:** eine echte Zahlung würde unbemerkt
|
||||
verschwinden; das Geld ist ja trotzdem angekommen.
|
||||
- **Warum nicht buchen:** es soll gerade nichts über PayPal laufen — die
|
||||
Entscheidung gehört dem Mandanten.
|
||||
- Die Zuordnungsseite bleibt für offene Zahlungen erreichbar (siehe
|
||||
`paypal_inbox_accessible()`), der Mandant sieht die geparkte Zahlung
|
||||
also und kann sie zuordnen oder abhaken.
|
||||
|
||||
### PayPal-Name lernen
|
||||
|
||||
Die automatische Zuordnung verlangt **exakte** Übereinstimmung des
|
||||
Zahlernamens mit `display_name` oder `paypal_name` des Mitglieds
|
||||
(`imports_find_participant()`, genau ein Treffer). Heißt jemand bei PayPal
|
||||
anders — Zweitname, Firmenkonto, anderer Nachname —, landet jede Zahlung in
|
||||
der Warteschlange, auch die zehnte.
|
||||
|
||||
`paypal_assign_payment()` hinterlegt den Zahlernamen deshalb beim Mitglied,
|
||||
sobald eine Zahlung von Hand zugeordnet wurde; die nächste Zahlung desselben
|
||||
Zahlers wird automatisch gebucht. Bewusst zurückhaltend
|
||||
(`paypal_remember_payer_name()`): ein bereits gepflegter PayPal-Name wird nie
|
||||
überschrieben, ein Name gleich dem Anzeigenamen bringt nichts, und passt der
|
||||
Name auch auf ein anderes Mitglied, wird nichts gelernt — sonst wäre die
|
||||
Zuordnung mehrdeutig und würde gar nicht mehr greifen. Was gelernt wurde,
|
||||
steht in der Erfolgsmeldung und im Audit-Log
|
||||
(`paypal_import.manual_assigned`, Feld `learned_paypal_name`).
|
||||
|
||||
### Zuordnung über die Mailadresse
|
||||
|
||||
Stand: 2026-08-21. `paypal_match_participant()` prüft in dieser Reihenfolge:
|
||||
|
||||
1. **Mailadresse des Zahlers** gegen `participants.email_norm` — im
|
||||
Mandanten eindeutig (Unique Key auf `tenant_id + email_norm`) und
|
||||
unabhängig davon, wie jemand bei PayPal heißt.
|
||||
2. Zahlername gegen `paypal_name`/`display_name` (genau ein Treffer).
|
||||
3. Mitteilung des Zahlers gegen dieselben Felder.
|
||||
|
||||
Die Adresse zieht `paypal_extract_payer_email()` aus der Mail und
|
||||
`paypal_payments.payer_email` (Migration `0028`) hält sie fest; in der
|
||||
Warteschlange steht sie unter dem Zahlernamen.
|
||||
|
||||
Das Heikle daran ist nicht das Finden, sondern das Aussortieren: In einer
|
||||
weitergeleiteten Mail stehen mehrere Adressen — die von PayPal, die
|
||||
Eingangsadresse der Kaffeeliste, je nach Weiterleitung die des
|
||||
ursprünglichen Empfängers. Würde davon die falsche genommen, bekäme das
|
||||
Mitglied hinter dieser Adresse fremdes Geld gutgeschrieben. Deshalb:
|
||||
|
||||
- alles auf `paypal.*` fällt raus,
|
||||
- die eigene Eingangsadresse fällt raus — geprüft gegen die tatsächliche
|
||||
Empfängeradresse der Mail (`paypal_same_mailbox()`, Plus-Adressierung
|
||||
wird ignoriert), nicht nur gegen `PAYPAL_INBOX_BASE`,
|
||||
- übrig bleibt nur eine Adresse, die **nahe am Namen des Zahlers** steht;
|
||||
ist keine in Reichweite, wird keine genommen und es bleibt beim
|
||||
Namensvergleich.
|
||||
|
||||
### Keine Absenderprüfung
|
||||
|
||||
Stand: 2026-08-21. Die Verarbeitung prüft **nicht**, ob die Mail von PayPal
|
||||
kommt. Der Grund ist praktisch: Zahlungsmails erreichen die Kaffeeliste auch
|
||||
über manuelle Weiterleitungen, und dann steht im Absender die Adresse des
|
||||
Weiterleitenden. Eine Absenderprüfung hätte genau diese Mails abgelehnt
|
||||
(`not_from_paypal`).
|
||||
|
||||
Maßgeblich ist allein die **Empfangsadresse**: ihr Plus-Token bestimmt den
|
||||
Mandanten (`paypal_inbox_extract_token()` → `paypal_inbox_resolve_tenant()`)
|
||||
und ist damit zugleich das Geheimnis, das den Zugang schützt. Wer die
|
||||
Adresse kennt, kann eine Mail im PayPal-Wortlaut schicken und eine
|
||||
Gutschrift auslösen — die Adresse gehört deshalb nicht in Verteiler,
|
||||
Signaturen oder Tickets. Als Bremsen bleiben: der Text muss als
|
||||
PayPal-Zahlungsmail erkennbar sein, jeder Transaktionscode wird nur einmal
|
||||
verbucht, und ohne eindeutige Zuordnung landet alles in der Warteschlange.
|
||||
|
||||
`paypal_from_is_paypal()` gibt es weiterhin, aber nur noch als Anzeige in
|
||||
`scripts/check-imap-support.php` („Absender sieht nach PayPal aus").
|
||||
|
||||
@@ -36,6 +36,11 @@ Wichtige Punkte:
|
||||
|
||||
- `--single-transaction` sorgt für einen konsistenten Snapshot ohne
|
||||
Tabellen zu sperren (InnoDB, wie hier durchgängig verwendet).
|
||||
- Da `test_` und `prod_` in derselben physischen Datenbank liegen, enthält
|
||||
der vollständige Dump beide logisch getrennten Schemas. Beim Restore darf
|
||||
nicht nur ein einzelner Prefix zurückgespielt werden, ohne Abhängigkeiten
|
||||
und den jeweiligen Stand von `test_schema_migrations` beziehungsweise
|
||||
`prod_schema_migrations` zu prüfen.
|
||||
- Der Backup-Ordner muss **außerhalb** des über HTTP erreichbaren Webroots
|
||||
liegen, sonst wären Dumps öffentlich abrufbar.
|
||||
- Zusätzlich zu lokalen Backups auf dem Webspace mindestens eine Kopie an
|
||||
@@ -55,7 +60,9 @@ gunzip -c kaffeeliste_20260715_030000.sql.gz | mysql -h "$DB_HOST" -u "$DB_USER"
|
||||
|
||||
Danach `php scripts/migrate.php` laufen lassen, falls das Backup älter als
|
||||
die zuletzt eingespielten Migrationen ist (das Skript ist idempotent und
|
||||
wendet nur fehlende Migrationen an).
|
||||
wendet nur fehlende Migrationen im konfigurierten `DB_TABLE_PREFIX` an).
|
||||
Den Lauf daher mit der Konfiguration jeder wiederhergestellten Umgebung
|
||||
separat ausführen.
|
||||
|
||||
**Restore-Test:** Mindestens einmal pro Quartal einen echten Restore gegen
|
||||
eine separate Test-Datenbank durchführen und die App dagegen starten
|
||||
|
||||
+78
-15
@@ -94,9 +94,10 @@ mandant-einstellungen.php (Upgrade-/Portal-Buttons)
|
||||
als auch auf der Subscription selbst als Metadaten gesetzt, damit spätere
|
||||
Subscription-Events (die kein Checkout-Session-Objekt mehr enthalten)
|
||||
trotzdem einem Mandanten zugeordnet werden können.
|
||||
- `abo-portal.php`: öffnet das Stripe Customer Portal für den bestehenden
|
||||
Stripe-Kunden (Zahlungsmittel ändern, Abo kündigen) – Self-Service ohne
|
||||
eigene UI dafür.
|
||||
- `abo-portal.php`: öffnet für den bestehenden Stripe-Kunden einen gezielten
|
||||
Customer-Portal-Flow ausschließlich zum Ändern des Zahlungsmittels. Die
|
||||
Portalnavigation ist in diesem Flow ausgeblendet; Tarifwechsel und
|
||||
Kündigungen laufen über die dokumentierten Funktionen der Anwendung.
|
||||
- `stripe-webhook.php`: verifiziert die `Stripe-Signature` nach dem von
|
||||
Stripe dokumentierten HMAC-SHA256-Schema (inklusive Zeitstempel-Toleranz
|
||||
gegen Replay), verarbeitet `checkout.session.completed`,
|
||||
@@ -106,9 +107,9 @@ mandant-einstellungen.php (Upgrade-/Portal-Buttons)
|
||||
unauthentifiziert von außen auf), stattdessen ausschließlich die
|
||||
Signaturprüfung als Echtheitsnachweis.
|
||||
- `mandant-einstellungen.php`: zeigt bei Bedarf einen echten
|
||||
„Jetzt upgraden"-Button (führt zu Stripe Checkout) sowie, sobald ein
|
||||
Stripe-Kunde existiert, „Zahlungsmethode verwalten / Abo kündigen"
|
||||
(führt zum Customer Portal).
|
||||
Bestellablauf (führt bei einer Neubuchung anschließend zu Stripe Checkout)
|
||||
sowie, sobald ein Stripe-Kunde existiert, „Zahlungsmethode verwalten"
|
||||
(gezielter Stripe-Portal-Flow).
|
||||
|
||||
**Bewusst nicht umgesetzt:** automatischer Stufenwechsel bei wachsender
|
||||
Teilnehmerzahl. Stattdessen gilt eine harte Obergrenze (siehe unten):
|
||||
@@ -168,7 +169,8 @@ scripts/check-billing-capacity.php (Regressionstest)
|
||||
`400` abgelehnt.
|
||||
- Customer Portal: mit einem echten, per API angelegten Stripe-Test-Kunden
|
||||
liefert `abo-portal.php` einen echten 302-Redirect zu
|
||||
`billing.stripe.com`.
|
||||
`billing.stripe.com`; vor Produktivfreigabe den neuen eingeschränkten
|
||||
Zahlungsarten-Flow erneut im Testmodus prüfen.
|
||||
- Alle bestehenden Regressionstests (Golden Master, M8-Isolation,
|
||||
M8-Rollenmatrix, HTTP-Smoke mit 36 Seiten) weiterhin grün.
|
||||
- Alle Testdaten (Stripe-Testobjekte kosten nichts und wurden im
|
||||
@@ -271,8 +273,9 @@ solange kein Pflicht-Upgrade anstand.
|
||||
Umgesetzte Dateien:
|
||||
|
||||
```text
|
||||
app/stripe.php (stripe_get_subscription, stripe_update_subscription_price, stripe_cancel_subscription)
|
||||
app/stripe.php (stripe_get_subscription, stripe_update_subscription_price, stripe_schedule_subscription_cancellation)
|
||||
app/billing.php (billing_plan_selectable_for)
|
||||
abo-bestellen.php (Bestellzusammenfassung und Rechtstextbestätigung)
|
||||
abo-upgrade.php (Fallunterscheidung Upgrade/Downgrade/Kündigung)
|
||||
mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-Button je Tarif)
|
||||
```
|
||||
@@ -286,12 +289,13 @@ mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-But
|
||||
- **`abo-upgrade.php`** unterscheidet jetzt drei Fälle statt nur
|
||||
„Checkout-Session erzeugen":
|
||||
1. Zielwert `free` mit bestehender bezahlter Subscription → echtes
|
||||
`stripe_cancel_subscription()`, lokal auf `free`/`canceled` gesetzt.
|
||||
`stripe_schedule_subscription_cancellation()` zum Ende der bereits
|
||||
bezahlten Periode; Tarif und Zugriff bleiben bis dahin erhalten.
|
||||
2. Zielwert ein anderer bezahlter Tarif **und** bereits eine
|
||||
aktive/`trialing`/`past_due`-Subscription vorhanden → Preis wird
|
||||
in-place über `stripe_update_subscription_price()` gewechselt
|
||||
(Stripe-Proration übernimmt die anteilige Verrechnung), kein neuer
|
||||
Checkout-Redirect nötig. Lokal sofort gespiegelt, der Webhook
|
||||
(ohne Zwischenbelastung oder Gutschrift in der laufenden Periode), kein
|
||||
neuer Checkout-Redirect nötig. Lokal sofort gespiegelt, der Webhook
|
||||
(`customer.subscription.updated`) bestätigt denselben Stand redundant.
|
||||
3. Kein aktives Abo bisher → wie vorher eine neue Stripe-Checkout-Session.
|
||||
- **`app/stripe.php`** neu: `stripe_get_subscription()` (liest Status,
|
||||
@@ -299,8 +303,9 @@ mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-But
|
||||
`stripe_update_subscription_price()` (Preis am bestehenden
|
||||
Subscription-Item austauschen, `cancel_at_period_end` explizit auf
|
||||
`false` – ein Tarifwechsel ist ein Signal, das Abo fortzuführen),
|
||||
`stripe_cancel_subscription()` (sofortige Kündigung, nicht zum
|
||||
Periodenende).
|
||||
`stripe_schedule_subscription_cancellation()` (ordentliche Kündigung zum
|
||||
Periodenende). `stripe_cancel_subscription()` bleibt dem wirksamen
|
||||
Verbraucherwiderruf vorbehalten.
|
||||
- **`app/billing.php`** neu: `billing_plan_selectable_for()` prüft, ob die
|
||||
aktive Teilnehmerzahl das Limit eines Zieltarifs einhält – Basis für das
|
||||
Deaktivieren nicht passender Tarife in der UI und für die
|
||||
@@ -315,8 +320,12 @@ mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-But
|
||||
- `enterprise` bleibt bewusst kein Selbstbedienungs-Ziel (kein
|
||||
Stripe-Preis vorhanden, Preis „auf Anfrage"); Auswahl verlinkt auf
|
||||
manuellen Kontakt statt eine falsche Buchung zu versuchen.
|
||||
- Bestätigungsdialog (JS `confirm()`) vor jedem Tarifwechsel, da ein
|
||||
Wechsel bei Stripe sofort wirksam wird und anteilig verrechnet wird.
|
||||
- Vor kostenpflichtiger Neubuchung oder Tarifwechsel zeigt
|
||||
`abo-bestellen.php` Leistung, Monatsgesamtpreis, Laufzeit, Kündigung und
|
||||
technische Voraussetzungen. Der Abschluss erfolgt über die eindeutig
|
||||
beschriftete Schaltfläche „zahlungspflichtig bestellen"; AGB,
|
||||
Widerrufsbelehrung und gewünschter sofortiger Leistungsbeginn werden
|
||||
versioniert protokolliert.
|
||||
|
||||
### Noch offen
|
||||
|
||||
@@ -327,3 +336,57 @@ mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-But
|
||||
Zugriff wurden hier durchgeführt. Vor Go-Live einmal im Stripe-Testmodus
|
||||
gegen einen echten Test-Mandanten durchspielen (Upgrade, Downgrade
|
||||
zwischen zwei bezahlten Stufen, Downgrade auf `free`).
|
||||
|
||||
## Hinweis auf die Kaffeeliste (Wasserzeichen, umgesetzt)
|
||||
|
||||
Stand: 2026-08-21. Unten rechts steht eine kleine Marke mit Logo und
|
||||
Adresse der Kaffeeliste. Im Tarif `free` (bis 10 Teilnehmer) ist sie fest
|
||||
eingeschaltet; ab einem bezahlten Tarif entscheidet der Mandant selbst
|
||||
(Mandant-Einstellungen, Standard: an).
|
||||
|
||||
Umgesetzte Dateien:
|
||||
|
||||
```text
|
||||
app/plan-watermark.php (Sichtbarkeit, Markup, Beschriftung)
|
||||
exportKaffeeliste.php (Marke im PDF-Fuss, gezeichnet statt eingebettet)
|
||||
app/bootstrap.php (app_marketing_url())
|
||||
app/saas-auth.php (Speichern, Zwang im kostenlosen Tarif)
|
||||
mandant-einstellungen.php (Schalter + Hinweis)
|
||||
database/migrations/0027_watermark_setting.sql (Spalte watermark_enabled)
|
||||
footer.php (Einbindung vor den Skripten)
|
||||
assets/css/app.css (.plan-watermark)
|
||||
scripts/http-smoke.php, scripts/check-m3-settings-flow.php (Regressionstests)
|
||||
```
|
||||
|
||||
- **Kostenlos heißt fest eingeschaltet.** `plan_watermark_visible()` zeigt
|
||||
die Marke immer, wenn `tenant_billing.plan_code` `free` ist – fehlt der
|
||||
Datensatz, gilt wie beim Anlegen `free`. Lässt sich der Tarif nicht
|
||||
lesen, bleibt die Oberfläche sauber: ein Wasserzeichen bei einem
|
||||
zahlenden Kunden wäre der schlimmere Fehler.
|
||||
- **Ab einem bezahlten Tarif entscheidet der Mandant.** Die Einstellung
|
||||
`tenant_settings.watermark_enabled` (Standard 1) steuert die Anzeige;
|
||||
der Schalter steht in den Mandant-Einstellungen. Im kostenlosen Tarif
|
||||
wird das Kästchen angehakt und `disabled` dargestellt, mit einem Hinweis
|
||||
daneben. Verlassen kann man sich darauf nicht – `saas_update_tenant_settings()`
|
||||
erzwingt den Wert im Tarif `free` noch einmal, damit auch ein
|
||||
nachgebauter POST ihn nicht abschalten kann.
|
||||
- **Keine Schreibzugriffe im Seitenaufbau.** Bewusst nicht
|
||||
`billing_fetch_or_init()`, das eine fehlende Zeile anlegen würde; die
|
||||
Prüfung ist eine reine Leseabfrage und wird pro Request zwischengespeichert.
|
||||
- **Im PDF mitgezeichnet.** Der Ausdruck traegt dieselbe Marke unten
|
||||
rechts (`MyCustomPDFWithWatermark::planWatermark()`), nach derselben
|
||||
Regel wie die Web-Ansicht. Die Tasse wird dort mit TCPDFs Zeichen-
|
||||
befehlen gemalt: `ImageSVG()` braucht die XML-Extension, ein Rasterbild
|
||||
GD - beides ist auf einem Webspace nicht garantiert. Steht die Marke,
|
||||
reserviert `export_page_row_budget_mm()` 6 mm am Seitenfuss, damit die
|
||||
Tabelle nicht hineinlaeuft (bei gesetzter Fusszeile deckt deren Band sie
|
||||
bereits ab).
|
||||
- **Logo als Inline-SVG.** Spart einen Request, bleibt auf jedem Bildschirm
|
||||
scharf und übernimmt die Farbe der Umgebung. Ein eigenes Bild lässt sich
|
||||
in `plan_watermark_logo_svg()` durch ein `<img>` ersetzen.
|
||||
- **Ziel der Verlinkung** ist die Marketing-Domain: `APP_MARKETING_URL`,
|
||||
ersatzweise `APP_HOST` ohne die App-Subdomain
|
||||
(`app.kaffeeliste.de` → `kaffeeliste.de`).
|
||||
- Getrennt von `app/branding.php`: dort geht es um das Logo *des Mandanten*
|
||||
(Kopfzeile), hier um das *des Anbieters*. Beides kann gleichzeitig
|
||||
sichtbar sein.
|
||||
|
||||
+301
-20
@@ -1,6 +1,6 @@
|
||||
# Deployment und Go-Live
|
||||
|
||||
Stand: 2026-08-06
|
||||
Stand: 2026-08-22
|
||||
|
||||
Dieses Dokument beschreibt, wie die Anwendung auf den Netcup-Webspace
|
||||
(Plesk) ausgerollt und in Betrieb genommen wird. Es ergänzt
|
||||
@@ -9,14 +9,22 @@ Dieses Dokument beschreibt, wie die Anwendung auf den Netcup-Webspace
|
||||
|
||||
## Umgebungen
|
||||
|
||||
| Umgebung | Host | Webroot | Zweck |
|
||||
| --- | --- | --- | --- |
|
||||
| Staging | `testumgebung.kaffeeliste.de` | `/testumgebung.kaffeeliste.de/httpdocs/` | Vollständiger Durchlauf vor jedem Produktiv-Deploy |
|
||||
| Produktion | `app.kaffeeliste.de` (App) + `kaffeeliste.de` (Marketing) | noch einzurichten | Echtbetrieb |
|
||||
| Umgebung | Host | Webroot | Tabellen-Prefix | Zweck |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Staging | `testumgebung.kaffeeliste.de` | `/testumgebung.kaffeeliste.de/httpdocs/` | `test_` | Vollständiger Durchlauf vor jedem Produktiv-Deploy |
|
||||
| Produktion | `app.kaffeeliste.de` (App) + `kaffeeliste.de` (Marketing) | noch einzurichten | `prod_` | Echtbetrieb |
|
||||
|
||||
Beide Umgebungen brauchen **eigene Datenbanken** und **eigene**
|
||||
`env.local.php`. Die Staging-Umgebung läuft mit Stripe-Testkeys, die
|
||||
Produktion mit Live-Keys.
|
||||
Beide Umgebungen verwenden dieselbe physische Datenbank, aber zwingend
|
||||
unterschiedliche Tabellen-Prefixe und jeweils eine eigene `env.local.php`.
|
||||
Damit sind Daten, Migrationstabellen und Fremdschlüssel von Staging und
|
||||
Produktion logisch getrennt. Die Staging-Umgebung läuft mit Stripe-Testkeys,
|
||||
die Produktion mit Live-Keys.
|
||||
|
||||
Der Prefix schützt vor versehentlichem Zugriff der einen Umgebung auf die
|
||||
Tabellen der anderen, ersetzt aber keine getrennte Datenbank als harte
|
||||
Sicherheitsgrenze: Ein kompromittierter gemeinsamer Datenbankzugang kann
|
||||
weiterhin alle Tabellen sehen. Deshalb Zugangsdaten besonders restriktiv
|
||||
behandeln und immer die gesamte Datenbank sichern.
|
||||
|
||||
### Host-Split beachten
|
||||
|
||||
@@ -54,6 +62,8 @@ die `.htaccess` gesperrt.
|
||||
Zusätzlich sperrt die `.htaccess` `var|database|scripts|docs|app|lib`,
|
||||
die Vendor-Verzeichnisse `TCPDF|PHPMailer|DataTables`, Dotfile-Ordner
|
||||
sowie Konfigurationsdateien und `.sql`/`.md`/`.jsonc`/`.sh`/`.log`-Dateien.
|
||||
Im Ordner `legal/` sind ausschließlich datierte `.txt`-Fassungen von AGB,
|
||||
Datenschutz, AVV und Widerrufsbelehrung als dauerhafte Downloads erlaubt.
|
||||
Sie erzwingt außerdem HTTPS — ohne Redirect käme ein Nutzer per `http` an
|
||||
und bekäme eine Session-Cookie ohne `secure`-Flag.
|
||||
|
||||
@@ -78,12 +88,13 @@ Die temporäre `phpinfo()`-Datei danach wieder löschen.
|
||||
|
||||
### 2. Datenbank anlegen
|
||||
|
||||
In Plesk eine leere MySQL-Datenbank samt eigenem Benutzer anlegen. Die
|
||||
Zugangsdaten werden gleich in `env.local.php` eingetragen.
|
||||
In Plesk eine MySQL-Datenbank samt Benutzer anlegen. Beide Umgebungen dürfen
|
||||
dieselben Zugangsdaten verwenden; die Trennung erfolgt über
|
||||
`DB_TABLE_PREFIX=test_` beziehungsweise `DB_TABLE_PREFIX=prod_`.
|
||||
|
||||
Die vorhandene Entwicklungsdatenbank **nicht** übernehmen: sie enthält
|
||||
ausschließlich Testdaten inklusive des migrierten Default-Mandanten aus
|
||||
der Legacy-App.
|
||||
Unpräfixierte vorhandene Testdaten dürfen ausschließlich mit dem dafür
|
||||
vorgesehenen Übernahmeskript nach `test_` kopiert werden. Sie dürfen niemals
|
||||
nach `prod_` übernommen werden.
|
||||
|
||||
### 3. Dateien hochladen
|
||||
|
||||
@@ -119,12 +130,16 @@ putenv('APP_TIMEZONE=Europe/Berlin');
|
||||
putenv('APP_DB_DRIVER=mysql');
|
||||
putenv('DB_HOST=localhost');
|
||||
putenv('DB_NAME=…'); putenv('DB_USER=…'); putenv('DB_PASS=…');
|
||||
putenv('DB_TABLE_PREFIX=test_');
|
||||
putenv('APP_SESSION_PATH=' . __DIR__ . '/var/sessions');
|
||||
putenv('APP_MAIL_TRANSPORT=mail');
|
||||
putenv('APP_MAIL_FROM=noreply@kaffeeliste.de');
|
||||
```
|
||||
|
||||
Stripe-, Dolibarr- und IMAP-Werte je Umgebung ergänzen (siehe unten).
|
||||
In der produktiven `env.local.php` muss stattdessen
|
||||
`DB_TABLE_PREFIX=prod_` stehen. Ein leerer Prefix ist im laufenden Betrieb
|
||||
nicht zulässig.
|
||||
|
||||
### 5. Schreibrechte für `var/`
|
||||
|
||||
@@ -135,9 +150,23 @@ den CSV-Import. Beide sind per `.htaccess` von außen gesperrt.
|
||||
|
||||
### 6. Migrationen einspielen
|
||||
|
||||
Es gibt 24 versionierte Migrationen in `database/migrations/`, angewendet
|
||||
Es gibt 32 versionierte Migrationen in `database/migrations/`, angewendet
|
||||
über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur
|
||||
fehlende Migrationen an.
|
||||
fehlende Migrationen an. Es arbeitet ausschließlich im Prefix aus der
|
||||
jeweiligen `env.local.php`; daher müssen Migrationen einmal in Staging und
|
||||
einmal in Produktion ausgeführt werden.
|
||||
|
||||
Für die einmalige Übernahme vorhandener, bisher unpräfixierter Testdaten:
|
||||
|
||||
```bash
|
||||
php scripts/migrate-table-prefix.php --target-prefix=test_ --replace
|
||||
```
|
||||
|
||||
Das Skript migriert zuerst das `test_`-Schema, ersetzt dessen Inhalt
|
||||
transaktional durch eine exakte Kopie der unpräfixierten Daten und vergleicht
|
||||
pro Tabelle die Zeilenzahl. Die unpräfixierten Quelltabellen bleiben als
|
||||
Rückfallmöglichkeit unverändert. Vor dem Lauf trotzdem ein vollständiges
|
||||
Datenbank-Backup erstellen.
|
||||
|
||||
Ohne SSH-Zugang läuft das über **Plesk → Geplante Aufgaben (Scheduled
|
||||
Tasks)** als einmalig ausgeführter Task vom Typ *PHP-Skript ausführen*:
|
||||
@@ -173,6 +202,8 @@ PayPal-Verbuchung und — kritisch — die Backups.
|
||||
| --- | --- | --- |
|
||||
| Zahlungserinnerungen | `/httpdocs/scripts/send-payment-reminders.php` | täglich, z. B. 07:00 |
|
||||
| PayPal-Mailabruf | `/httpdocs/scripts/fetch-paypal-payments.php` | alle 15–30 Minuten |
|
||||
| Technische Löschfristen | `/httpdocs/scripts/purge-expired-operational-data.php` | stündlich |
|
||||
| Beendete Verträge löschen | `/httpdocs/scripts/purge-ended-contracts.php` | täglich |
|
||||
| Datenbank-Backup | Shell-Befehl, siehe `docs/betrieb-backup-monitoring.md` | täglich nachts |
|
||||
|
||||
Zum Backup-Task: Das Zielverzeichnis muss **außerhalb** von `httpdocs`
|
||||
@@ -181,8 +212,10 @@ liegen (z. B. `/backups` auf Vhost-Ebene), sonst wären die Dumps
|
||||
Ort übertragen — ein Backup, das nur auf demselben Webspace liegt,
|
||||
schützt nicht gegen den Ausfall des Anbieters.
|
||||
|
||||
Vor der Scharfschaltung beide PHP-Tasks einmal manuell auslösen.
|
||||
`send-payment-reminders.php` unterstützt dafür `--dry-run`.
|
||||
Vor der Scharfschaltung die Aufgaben mit Lösch- oder Buchungswirkung zuerst
|
||||
manuell mit `--dry-run` auslösen. Dies unterstützen
|
||||
`send-payment-reminders.php`, `fetch-paypal-payments.php`,
|
||||
`purge-expired-operational-data.php` und `purge-ended-contracts.php`.
|
||||
|
||||
## Mailversand und DNS
|
||||
|
||||
@@ -227,6 +260,179 @@ Details zur Funktionsweise stehen in `docs/billing.md`. Für den Go-Live:
|
||||
echte Zahlungseingang ist dessen erster Test. Danach in Dolibarr
|
||||
kontrollieren, ob die Rechnung korrekt angelegt und validiert wurde.
|
||||
|
||||
Der Link „Zahlungsmethode verwalten“ startet technisch ausschließlich einen
|
||||
Stripe-Flow zur Aktualisierung der Zahlungsart. Im Stripe-Dashboard dürfen der
|
||||
öffentliche No-Code-Portal-Login und Tarifwechsel außerhalb der Anwendung nicht
|
||||
zusätzlich freigeschaltet werden; Bestellungen und Kündigungen sollen nur über
|
||||
die dokumentierten Abläufe der Kaffeeliste erfolgen.
|
||||
|
||||
## Zwei-Faktor-Anmeldung
|
||||
|
||||
Jedes Konto kann unter `zwei-faktor-einrichten.php` (verlinkt aus
|
||||
`konto.php`) einen zweiten Faktor per TOTP einrichten — dieselben
|
||||
sechsstelligen Codes, die Aegis, 2FAS, Google Authenticator oder ein
|
||||
Passwortmanager erzeugen.
|
||||
|
||||
`app/totp.php` implementiert RFC 6238 selbst, statt eine Bibliothek zu
|
||||
ziehen: der Algorithmus ist ein HMAC plus eine Truncation, und ein zweiter
|
||||
Faktor ist die letzte Stelle, an der man ungeprüfte Abhängigkeiten haben
|
||||
will. Der QR-Code entsteht aus dem ohnehin vorhandenen TCPDF — kein
|
||||
externer Dienst bekommt das Geheimnis zu sehen.
|
||||
|
||||
Ablauf beim Login: stimmt das Passwort und ist ein zweiter Faktor scharf,
|
||||
wird die Anmeldung **nicht** abgeschlossen. Es entsteht nur ein
|
||||
Zwischenzustand (`totp_pending_user_id`, 15 Minuten gültig), der für sich
|
||||
genommen keinerlei Zugriff gewährt; erst der Code stellt die Sitzung her.
|
||||
Wer mehrere Mandanten hat, kommt auch erst danach zur Mandantenauswahl.
|
||||
|
||||
Weitere Festlegungen:
|
||||
|
||||
- **Wiederherstellungscodes**: zehn Stück, nur direkt nach der Einrichtung
|
||||
im Klartext sichtbar, gespeichert als Hash, jeder genau einmal gültig.
|
||||
Auf der Code-Seite genügt es, statt des App-Codes einen davon einzugeben —
|
||||
Buchstaben im Feld unterscheiden die beiden Fälle.
|
||||
- **Wiederverwendung ausgeschlossen**: `users.totp_last_step` merkt sich den
|
||||
zuletzt eingelösten Zeitschritt. Ohne das ließe sich ein abgefangener Code
|
||||
innerhalb seines Gültigkeitsfensters ein zweites Mal verwenden.
|
||||
- **Toleranz** von einem Zeitschritt (±30 s) für Uhrenabweichungen.
|
||||
- **Rate-Limit** auf der Code-Eingabe: 5 Versuche pro Konto und 20 pro IP je
|
||||
15 Minuten — sechs Ziffern sind sonst schnell durchprobiert.
|
||||
- **Abschalten** verlangt Passwort *und* gültigen Code.
|
||||
|
||||
`APP_REQUIRE_2FA_FOR_ADMINS=1` macht den zweiten Faktor für Platform-Admins
|
||||
verbindlich: das Back-Office leitet dann auf die Einrichtung um, statt hart
|
||||
zu sperren — sonst könnte der Schalter den einzigen Platform-Admin dauerhaft
|
||||
aussperren. **Erst umlegen, nachdem die eigene Einrichtung getestet ist.**
|
||||
|
||||
Abgedeckt von `scripts/check-totp-flow.php` (Algorithmus inklusive der
|
||||
RFC-6238-Testvektoren, Persistenz, Wiederherstellungscodes) und
|
||||
`scripts/check-2fa-http-flow.php` (Anmeldeweg; braucht einen laufenden
|
||||
Webserver).
|
||||
|
||||
## Schutz der offenen Registrierung
|
||||
|
||||
Ab Juli 2026 liefen auf `testumgebung.kaffeeliste.de` täglich 10–20
|
||||
automatisierte Registrierungen: zufällige Namen kombiniert mit **fremden,
|
||||
echten** E-Mail-Adressen. Der Server verschickte daraufhin Vertrags- und
|
||||
Verifikationsmail an Leute, die nie etwas bestellt hatten — Mail-Bombing
|
||||
über die eigene Domain, auf Kosten der Zustellbarkeit. Das IP-Rate-Limit
|
||||
griff nicht, weil jede Anfrage über eine eigene Rechenzentrums-IP kam.
|
||||
|
||||
`app/spam-guard.php` stellt deshalb vor `register.php`:
|
||||
|
||||
- **Honigtopf** — ein für Menschen unsichtbares Feld (`contact_reference`),
|
||||
das Formular-Bots ausfüllen. Die Positionierung steht bewusst inline und
|
||||
nicht in `public.css`: lädt das Stylesheet einmal nicht, wäre das Feld
|
||||
sonst sichtbar und ein echter Nutzer könnte hineinschreiben.
|
||||
- **Zeitfalle** — mindestens vier Sekunden zwischen Ausliefern und
|
||||
Absenden des Formulars.
|
||||
- **Globale Notbremse** — höchstens 20 Registrierungen pro Stunde über
|
||||
alle IPs hinweg, zusätzlich zum bestehenden Limit von 5 pro IP.
|
||||
- **Domain-Prüfung** — die Mail-Domain muss einen MX- oder A/AAAA-Record
|
||||
haben. Fehlt `checkdnsrr()`, wird durchgelassen: eine kaputte
|
||||
DNS-Auflösung darf keine echten Registrierungen blockieren.
|
||||
|
||||
Honigtopf, Zeitfalle und beide Rate-Limits antworten mit **derselben**
|
||||
generischen Meldung, damit die Antwort nicht verrät, welche Hürde
|
||||
angeschlagen hat. Treffer stehen im PHP-Error-Log (`Spam-Guard: …`) — dort
|
||||
lässt sich ablesen, ob die Maßnahmen greifen. Nur die Domain-Prüfung nennt
|
||||
den Grund, weil das in aller Regel ein Tippfehler eines echten Nutzers ist.
|
||||
|
||||
Bewusst **kein** Captcha: Turnstile oder hCaptcha wären wirksam, holen aber
|
||||
einen Drittanbieter in den Registrierungspfad und damit in die
|
||||
Datenschutzerklärung. Erst wenn die obigen Hürden nachweislich nicht
|
||||
reichen, ist das die nächste Stufe.
|
||||
|
||||
Abgedeckt von `scripts/check-spam-guard.php` (ohne Datenbank lauffähig).
|
||||
|
||||
## Matomo und Analyse-Einwilligung
|
||||
|
||||
Die Anwendung enthält eine eigene Einwilligungsoberfläche und lädt Matomo
|
||||
erst nach „Analyse erlauben“. Ohne vollständige Konfiguration wird weder die
|
||||
Oberfläche ausgegeben noch eine Verbindung zu Matomo aufgebaut. Für die
|
||||
Produktion in `env.local.php` ergänzen:
|
||||
|
||||
```php
|
||||
putenv('MATOMO_URL=https://analytics.kaffeeliste.de/');
|
||||
putenv('MATOMO_SITE_ID=1');
|
||||
putenv('MATOMO_CONSENT_COOKIE_DOMAIN=.kaffeeliste.de');
|
||||
putenv('MATOMO_PRIVACY_CONFIRMED=1');
|
||||
```
|
||||
|
||||
`MATOMO_SITE_ID` durch die numerische Website-ID ersetzen. Die gemeinsame
|
||||
Cookie-Domain ist nur richtig, wenn Marketingseite und App unter
|
||||
`kaffeeliste.de` laufen und dieselbe Entscheidung sowie Besucherkennung nutzen
|
||||
sollen. Der Host der selbst betriebenen Matomo-Instanz darf davon abweichen.
|
||||
Sonst leer lassen; die Einwilligung gilt dann nur auf dem jeweils besuchten
|
||||
Host.
|
||||
|
||||
`MATOMO_PRIVACY_CONFIRMED=1` erst setzen, nachdem die Matomo-Instanz so
|
||||
konfiguriert und geprüft wurde:
|
||||
|
||||
1. Matomo wird selbst auf der im Datenschutzhinweis genannten EU-Infrastruktur
|
||||
betrieben; Matomo Cloud oder ein anderer Empfänger ist nicht eingetragen.
|
||||
2. Unter Datenschutz/Anonymisierung werden IP-Adressen vor Speicherung um
|
||||
mindestens zwei Bytes gekürzt.
|
||||
3. Roh- und Besuchsprotokolle werden spätestens nach 180 Tagen automatisch
|
||||
gelöscht; verdichtete Berichte spätestens nach 25 Monaten.
|
||||
4. Keine User-ID, benutzerbezogene Custom Dimensions, Heatmaps,
|
||||
Sitzungsaufzeichnungen oder Werbeintegrationen aktivieren. Der
|
||||
First-Party-Tracker übermittelt nur den bereinigten Seitenaufruf, eine
|
||||
zufällige Besucherkennung und die im Datenschutzhinweis genannten wenigen
|
||||
Browserangaben; Performance- und Link-Klick-Messung bleiben deaktiviert.
|
||||
5. In Matomo ist genau eine Website mit den erlaubten Marketing- und
|
||||
App-Domains angelegt. Testaufrufe mit Query-Parametern dürfen in Matomo nur
|
||||
den Pfad ohne Query oder Fragment zeigen.
|
||||
6. Im privaten Browserfenster prüfen: Vor der Entscheidung und nach „Nur
|
||||
notwendige“ gibt es keine Anfrage an `nutzungsanalyse.php` und kein
|
||||
`kaffeeliste_analytics_visitor`-Cookie. Nach Zustimmung antwortet der lokale
|
||||
POST an `nutzungsanalyse.php` mit HTTP 204 und der Aufruf erscheint in
|
||||
Matomo. Ein anschließender Widerruf über „Cookie-Einstellungen“ stoppt
|
||||
weitere Aufrufe und löscht das Besuchercookie.
|
||||
|
||||
Die Entscheidung wird 180 Tage im Cookie
|
||||
`kaffeeliste_analytics_consent` gespeichert. Eine Versionsänderung in
|
||||
`APP_ANALYTICS_CONSENT_VERSION` fragt erneut. Änderungen an Zweck, Datenumfang
|
||||
oder Empfängern erfordern außerdem eine neue Datenschutzerklärung und eine
|
||||
erneute rechtliche Prüfung.
|
||||
|
||||
Der Browser lädt weder `matomo.js` noch `matomo.php` vom Analysehost. Nach der
|
||||
Einwilligung sendet `assets/js/privacy-settings.js` genau einen bereinigten
|
||||
Seitenaufruf an den gleichnamigen Host-Endpunkt `nutzungsanalyse.php`. Dieser
|
||||
validiert Einwilligung, Origin, Besucherkennung und URL und leitet den Aufruf
|
||||
serverseitig an die feste `MATOMO_URL`/`MATOMO_SITE_ID` weiter. Die Client-IP
|
||||
wird nicht als Tracking-Parameter an Matomo übermittelt.
|
||||
|
||||
### Anonyme Grundzählung ohne Einwilligung
|
||||
|
||||
Unabhängig von Matomo zählt `app/public-page-views.php` die GET-Aufrufe
|
||||
ausgewählter öffentlicher HTML-Seiten. In `public_page_views` stehen nur der
|
||||
Kalendertag, ein fester Seitenschlüssel und ein Summenzähler. IP-Adresse,
|
||||
User-Agent, Referrer, vollständige URL, Query-Parameter, Cookies und
|
||||
Benutzerkennungen werden weder gelesen noch in diese Statistik übernommen.
|
||||
|
||||
Die Auswertung steht ausschließlich Platform-Admins im Back-Office zur
|
||||
Verfügung und zeigt heute, die letzten 30 Tage, den Gesamtstand sowie die
|
||||
Aufteilung je Seite. Sie misst Seitenaufrufe, keine eindeutigen Besucher;
|
||||
Wiederholungen und Bots können enthalten sein. Die Zählung beginnt erst nach
|
||||
Anwendung von `0032_public_page_views.sql` und kann frühere Zugriffe nicht
|
||||
rückwirkend rekonstruieren.
|
||||
|
||||
## Betreiber-Benachrichtigung bei neuer Registrierung
|
||||
|
||||
`register.php` schickt nach einer erfolgreichen Selbstregistrierung einen
|
||||
Pushover-Push an den Betreiber (`app/pushover.php`). Dafür in
|
||||
`env.local.php` `PUSHOVER_TOKEN` (App-Token aus dem Pushover-Dashboard) und
|
||||
`PUSHOVER_USER` (eigener User- oder Group-Key) setzen. Fehlt einer der
|
||||
beiden Werte, ist die Benachrichtigung still deaktiviert — auf Staging also
|
||||
einfach leer lassen.
|
||||
|
||||
Der Versand ist bewusst folgenlos: Schlägt er fehl, landet die Ursache im
|
||||
PHP-Error-Log, die Registrierung läuft trotzdem durch. Der Aufruf sitzt
|
||||
deshalb in `register.php` und nicht in `saas_register_tenant_owner()` — die
|
||||
`check-*`-Skripte in `scripts/` registrieren Testmandanten und würden sonst
|
||||
bei jedem Lauf Pushes auslösen.
|
||||
|
||||
## PayPal-Postfach
|
||||
|
||||
Für die automatische Verbuchung weitergeleiteter PayPal-Zahlungsmails
|
||||
@@ -236,6 +442,52 @@ dieselbe Mailbox zustellen** (Catch-All), damit die pro Mandant
|
||||
generierten Adressen `zahlungen+<token>@…` ankommen. IMAP-Zugangsdaten in
|
||||
`env.local.php` eintragen und die `imap`-Extension auf dem Host prüfen.
|
||||
|
||||
### Erreichbarkeit prüfen
|
||||
|
||||
`scripts/check-imap-support.php` beantwortet in einem Lauf, ob dieser Host
|
||||
das Postfach erreichen kann: PHP-Version, ob die `imap`-Erweiterung
|
||||
geladen ist, ob die `PAYPAL_*`-Einstellungen gesetzt sind, und ob sich das
|
||||
Postfach **auch ohne die Erweiterung** über eine reine TLS-Verbindung
|
||||
öffnen lässt. Der Test ist nur lesend (`EXAMINE`, keine Flags), das
|
||||
Passwort wird nie ausgegeben.
|
||||
|
||||
Fehlt die Erweiterung: In Plesk unter *PHP-Einstellungen* prüfen, ob
|
||||
`imap` angeboten wird — nachinstallieren kann sie nur der Anbieter
|
||||
(`plesk-php8x-imap`). Seit **PHP 8.4** ist `imap` kein Bestandteil von PHP
|
||||
mehr (nach PECL ausgelagert); auf 8.4/8.5 steht sie deshalb oft gar nicht
|
||||
mehr zur Wahl, dann hilft ein PHP-Handler auf 8.3 — oder ein Abruf über
|
||||
die TLS-Verbindung, die das Diagnoseskript bereits nachweist.
|
||||
|
||||
### Erster Testlauf
|
||||
|
||||
`scripts/fetch-paypal-payments.php --dry-run` fasst nichts an: es meldet
|
||||
Verbindung, erkannten Mandanten und was mit jeder Mail passieren *würde*
|
||||
(`would_book` / `would_queue` / `would_park` / `duplicate`), speichert
|
||||
aber nichts, bucht nichts und markiert keine Mail als gelesen. Erst der
|
||||
Lauf ohne `--dry-run` verbucht.
|
||||
|
||||
Reihenfolge für die Inbetriebnahme:
|
||||
|
||||
1. Eingangsadresse des Mandanten holen — sie steht auf
|
||||
`paypal-zuordnung.php` („PayPal-Zahlungen").
|
||||
2. Eine echte PayPal-Zahlungsmail dorthin weiterleiten.
|
||||
3. Task mit `--dry-run` auslösen, Ausgabe kontrollieren.
|
||||
4. Task ohne `--dry-run` auslösen, Ergebnis in der App prüfen
|
||||
(Journal-Vorschau bzw. Warteschlange).
|
||||
5. Erst danach den Task auf den regelmäßigen Takt stellen.
|
||||
|
||||
Ohne IMAP lässt sich die Verarbeitung auch mit einer gespeicherten Mail
|
||||
prüfen:
|
||||
|
||||
```
|
||||
php scripts/fetch-paypal-payments.php --file=mail.html \
|
||||
--recipient=zahlungen+<token>@kaffeeliste.de [--dry-run]
|
||||
```
|
||||
|
||||
Die Verarbeitungskette selbst (Token, Parser, Zuordnung, Buchung, Dedup,
|
||||
Parken) deckt `scripts/check-paypal-inbox-flow.php` als
|
||||
Regressionstest ab — alles außer der IMAP-Verbindung.
|
||||
|
||||
## Deploy-Ablauf für ein Update
|
||||
|
||||
1. Lokal committen und pushen.
|
||||
@@ -253,6 +505,16 @@ frisches Backup ziehen.
|
||||
|
||||
Vor der Umstellung auf `app.kaffeeliste.de`:
|
||||
|
||||
Zuerst aus dem Projektverzeichnis ausführen:
|
||||
|
||||
```
|
||||
php scripts/check-production-readiness.php
|
||||
```
|
||||
|
||||
Der Check blockiert unter anderem bei fehlender B2C-Telefonnummer,
|
||||
unsicherer Basis-URL, fehlenden Zahlungs-/Abrechnungsschlüsseln, zu offenen
|
||||
Rechten der Secret-Dateien oder einer nicht angewendeten Legal-Migration.
|
||||
|
||||
- [ ] Kompletter Funktionsdurchlauf auf Staging erfolgreich
|
||||
(Registrierung, Mailversand, Mitglieder, Striche, Einzahlungen,
|
||||
CSV-Import, PDF-Export, Jahresauswertung, Stripe im Testmodus)
|
||||
@@ -262,9 +524,28 @@ Vor der Umstellung auf `app.kaffeeliste.de`:
|
||||
- [ ] Backup-Task läuft und ein Restore wurde einmal testweise
|
||||
eingespielt (`docs/betrieb-backup-monitoring.md`)
|
||||
- [ ] Stripe-Live-Keys und Produktiv-Webhook eingetragen
|
||||
- [ ] Impressum, AGB und Datenschutzerklärung inhaltlich freigegeben
|
||||
- [ ] Auftragsverarbeitungsvertrag (AVV) für Kunden vorbereitet
|
||||
- [ ] Frische Produktivdatenbank ohne Testdaten
|
||||
- [ ] `LEGAL_PHONE`, `LEGAL_EMAIL` und `LEGAL_TICKET_URL` zeigen auf
|
||||
tatsächlich betreute Kontaktwege; Tickets werden werktags regelmäßig
|
||||
bearbeitet
|
||||
- [ ] Impressum, AGB, Datenschutz, AVV und Widerrufsbelehrung inhaltlich
|
||||
anwaltlich für B2B und B2C freigegeben
|
||||
- [ ] Kündigungs- und Widerrufsformular erzeugen beim Kunden und bei
|
||||
`LEGAL_EMAIL` eine E-Mail; die Textbestätigung lässt sich speichern
|
||||
- [ ] Offene Rechtserklärungen werden täglich im Back-Office geprüft;
|
||||
Rückzahlungen nach Widerruf werden manuell über Stripe fristgerecht
|
||||
bearbeitet (die Anwendung beendet das Abo, erstattet aber nicht automatisch)
|
||||
- [ ] Stripe Checkout läuft auf Deutsch, zeigt Endpreis und Monatslaufzeit;
|
||||
Test von Neubuchung, Tarifwechsel und Kündigung zum Periodenende
|
||||
- [ ] Webserver-Access-Logs werden spätestens nach sieben Tagen gelöscht oder
|
||||
anonymisiert; TLS/HSTS ist auf allen öffentlichen Domains aktiv
|
||||
- [ ] Falls Matomo aktiv ist: Datenschutz-Checkliste oben vollständig geprüft,
|
||||
Opt-in/Ablehnung/Widerruf im Browser getestet und
|
||||
`MATOMO_PRIVACY_CONFIRMED=1` gesetzt
|
||||
- [ ] Täglicher Task `php scripts/purge-ended-contracts.php` ist eingerichtet;
|
||||
ein Vorlauf mit `--dry-run` wurde kontrolliert
|
||||
- [ ] Stündlicher Task `php scripts/purge-expired-operational-data.php` ist
|
||||
eingerichtet; ein Vorlauf mit `--dry-run` wurde kontrolliert
|
||||
- [ ] Frisches `prod_`-Schema ohne Testdaten; Staging verwendet ausschließlich `test_`
|
||||
|
||||
## Bewusst offen
|
||||
|
||||
|
||||
+26
-1
@@ -13,6 +13,28 @@ auf PDO/MySQL abbildet.
|
||||
In dieser VS-Code-Server-Umgebung wurde PHP lokal unter `.local/` entpackt,
|
||||
weil `sudo` nicht passwordless verfügbar war. `.local/` wird von Git ignoriert.
|
||||
|
||||
### Nachträglich eine Erweiterung ergänzen (Beispiel `imap`)
|
||||
|
||||
Ohne Root lässt sich ein `.deb` trotzdem auspacken und einbinden — so kam
|
||||
die `imap`-Erweiterung für den PayPal-Mailabruf dazu:
|
||||
|
||||
```bash
|
||||
cd .local/debs
|
||||
curl -O http://archive.ubuntu.com/ubuntu/pool/universe/p/php8.3/php8.3-imap_8.3.6-0ubuntu0.24.04.10_amd64.deb
|
||||
curl -O http://archive.ubuntu.com/ubuntu/pool/universe/u/uw-imap/libc-client2007e_2007f~dfsg-7build3_amd64.deb
|
||||
dpkg-deb -x php8.3-imap_*.deb ../php
|
||||
dpkg-deb -x libc-client2007e_*.deb ../php
|
||||
# imap.so sucht libc-client.so.2007e im LD_LIBRARY_PATH aus .env.local:
|
||||
cp -a ../php/usr/lib/libc-client.so.2007e.0 ../php/usr/lib/x86_64-linux-gnu/
|
||||
ln -sf libc-client.so.2007e.0 ../php/usr/lib/x86_64-linux-gnu/libc-client.so.2007e
|
||||
echo 'extension=imap.so' >> ../php-dev.ini
|
||||
```
|
||||
|
||||
Die Paketversion muss zur PHP-Version passen (`20230831` = PHP 8.3). Auf
|
||||
dem Webspace geht das **nicht** — dort entscheidet der Anbieter, welche
|
||||
Erweiterungen es gibt; siehe `docs/deployment.md`, Abschnitt
|
||||
PayPal-Postfach.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
```bash
|
||||
@@ -22,6 +44,7 @@ export DB_PORT="3306"
|
||||
export DB_NAME="example_database"
|
||||
export DB_USER="example_user"
|
||||
export DB_PASS="example_password"
|
||||
export DB_TABLE_PREFIX="test_"
|
||||
export DEV_AUTH_EMAIL="admin@test.local"
|
||||
export DEV_AUTH_NAME="Test Admin"
|
||||
# Optional: Standard ist var/sessions im Repository.
|
||||
@@ -73,7 +96,9 @@ der Port über die Ports-Ansicht weitergeleitet werden.
|
||||
Nachrichten unter `var/mail` ab. Für Produktion kann zunächst
|
||||
`APP_MAIL_TRANSPORT=mail` mit passendem `APP_BASE_URL` und Absender gesetzt
|
||||
werden; eine SMTP-/Provider-Anbindung bleibt ein späterer Betriebsausbau.
|
||||
- Die Tabellen werden über `database/migrations/` angelegt.
|
||||
- Die Tabellen werden über `database/migrations/` mit dem Prefix aus
|
||||
`DB_TABLE_PREFIX` angelegt. Lokal und auf Staging ist das `test_`, in
|
||||
Produktion ausschließlich `prod_`.
|
||||
- `database/mysql-dev-schema.sql` bleibt als historische Dev-Schema-Baseline
|
||||
erhalten; der aktive Weg ist `scripts/migrate.php`.
|
||||
- Session-Dateien liegen standardmäßig unter `var/sessions`; `var/` wird von
|
||||
|
||||
@@ -111,8 +111,8 @@ LD_LIBRARY_PATH="$PWD/.local/php/usr/lib/x86_64-linux-gnu:$PWD/.local/php/usr/li
|
||||
```
|
||||
|
||||
Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`,
|
||||
`DB_USER` und `DB_PASS`. `scripts/init-mysql-dev.php` braucht zusätzlich
|
||||
`DEV_AUTH_EMAIL`.
|
||||
`DB_USER`, `DB_PASS` und `DB_TABLE_PREFIX`. `scripts/init-mysql-dev.php`
|
||||
braucht zusätzlich `DEV_AUTH_EMAIL`.
|
||||
|
||||
## Aktueller Prüfstatus
|
||||
|
||||
|
||||
+16
-1
@@ -4,6 +4,7 @@ ob_start();
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
|
||||
@@ -193,7 +194,17 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" ) {
|
||||
$action = (string)($_GET['action'] ?? 'alle');
|
||||
}
|
||||
|
||||
$mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
// Vorder-/Rueckseite nur, wenn der Ausdruck auch wirklich zwei Seiten hat
|
||||
// (siehe ledger_uses_two_sided_sheet). Bei kleinen Teams gibt es nur ein Blatt.
|
||||
$alleAktiven = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$zweiseitig = ledger_uses_two_sided_sheet(count($alleAktiven));
|
||||
if (!$zweiseitig) {
|
||||
$action = 'alle';
|
||||
}
|
||||
|
||||
$mitarbeiter = $action === 'alle'
|
||||
? $alleAktiven
|
||||
: einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
|
||||
?>
|
||||
|
||||
@@ -223,6 +234,7 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
</div>
|
||||
|
||||
<ul class="actions">
|
||||
<?php if ($zweiseitig): ?>
|
||||
<li>
|
||||
<form action="einzahlung.php" method="get">
|
||||
<input type="hidden" name="action" value="vorderseite">
|
||||
@@ -235,6 +247,7 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
<button type="submit">Rückseite</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<form action="einzahlung.php" method="get">
|
||||
<input type="hidden" name="action" value="alle">
|
||||
@@ -246,11 +259,13 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
<button type="submit">Letzten Einträge</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'csv_import')): ?>
|
||||
<li>
|
||||
<form action="csvupload.php" method="get">
|
||||
<button type="submit">CSV Upload</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
|
||||
@@ -18,6 +18,7 @@ $result = $token !== ''
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
@@ -62,9 +63,15 @@ $result = $token !== ''
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
<a href="avv.php">AVV</a>
|
||||
<a href="widerruf.php">Widerrufsbelehrung</a>
|
||||
<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>
|
||||
<a href="preise.php">Preise</a>
|
||||
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||
<?php echo app_analytics_footer_settings_button_html(); ?>
|
||||
</footer>
|
||||
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
@@ -23,6 +23,24 @@ putenv('APP_TIMEZONE=Europe/Berlin');
|
||||
// Dashboard/Login/etc.; jeder andere Host (z. B. kaffeeliste.de) zeigt
|
||||
// stattdessen die Landingpage. Siehe index.php.
|
||||
putenv('APP_HOST=app.kaffeeliste.de');
|
||||
putenv('APP_BASE_URL=https://app.kaffeeliste.de');
|
||||
|
||||
// Ziel des Wasserzeichens im kostenlosen Tarif (unten rechts in der App).
|
||||
// Ohne Angabe wird die Marketing-Domain aus APP_HOST abgeleitet, indem die
|
||||
// App-Subdomain wegfaellt - hier also https://kaffeeliste.de.
|
||||
putenv('APP_MARKETING_URL=https://kaffeeliste.de');
|
||||
|
||||
// Optionale, erst nach Einwilligung geladene Nutzungsanalyse. Beide Domains
|
||||
// koennen dieselbe Entscheidung und Matomo-Besucherkennung verwenden, wenn
|
||||
// die Cookie-Domain auf die gemeinsame Eltern-Domain gesetzt wird.
|
||||
// Vor MATOMO_PRIVACY_CONFIRMED=1 die Checkliste in docs/deployment.md
|
||||
// (IP-Kuerzung, Loeschfristen, keine Heatmaps/Sitzungsaufzeichnung) abarbeiten.
|
||||
// Fehlen URL und Site-ID, ist Matomo vollstaendig deaktiviert und es erscheint
|
||||
// kein Einwilligungsbanner.
|
||||
putenv('MATOMO_URL='); // Beispiel: https://analytics.kaffeeliste.de/
|
||||
putenv('MATOMO_SITE_ID='); // Numerische Website-ID aus Matomo
|
||||
putenv('MATOMO_CONSENT_COOKIE_DOMAIN='); // Produktion: .kaffeeliste.de
|
||||
putenv('MATOMO_PRIVACY_CONFIRMED=0');
|
||||
|
||||
putenv('APP_DB_DRIVER=mysql');
|
||||
putenv('DB_HOST=');
|
||||
@@ -30,6 +48,9 @@ putenv('DB_PORT=3306');
|
||||
putenv('DB_NAME=');
|
||||
putenv('DB_USER=');
|
||||
putenv('DB_PASS=');
|
||||
// Bei gemeinsamer physischer Datenbank zwingend pro Umgebung verschieden:
|
||||
// Produktion `prod_`, Test/Staging `test_`. Der Prefix endet immer auf `_`.
|
||||
putenv('DB_TABLE_PREFIX=prod_');
|
||||
|
||||
// Muss ausserhalb des oeffentlichen Webroots oder zumindest per .htaccess
|
||||
// gesperrt sein - siehe Deployment-Hinweise.
|
||||
@@ -39,6 +60,12 @@ putenv('APP_MAIL_TRANSPORT=mail');
|
||||
putenv('APP_MAIL_FROM=noreply@kaffeeliste.de');
|
||||
putenv('APP_MAIL_FROM_NAME=Kaffeeliste');
|
||||
|
||||
// Pflichtangaben und schneller Kontakt. Fuer B2C muss eine Telefonnummer
|
||||
// angegeben werden; das Ticketsystem ergaenzt sie als dokumentierbarer Kanal.
|
||||
putenv('LEGAL_EMAIL=info@ctb-it.de');
|
||||
putenv('LEGAL_PHONE=');
|
||||
putenv('LEGAL_TICKET_URL=https://ticketsystem.ctb-it.de');
|
||||
|
||||
putenv('STRIPE_SECRET_KEY=');
|
||||
putenv('STRIPE_PUBLISHABLE_KEY=');
|
||||
putenv('STRIPE_WEBHOOK_SECRET=');
|
||||
@@ -66,3 +93,15 @@ putenv('PAYPAL_IMAP_MAILBOX=INBOX');
|
||||
// die Anmeldung laeuft immer ueber login.php, auch lokal. Die Variable wird nur
|
||||
// noch von scripts/init-mysql-dev.php gelesen, um beim Aufsetzen einer
|
||||
// Entwicklungsdatenbank den ersten Benutzer anzulegen.
|
||||
|
||||
// Betreiber-Push bei neuer Kundenregistrierung (optional). Beide Werte
|
||||
// stehen im Pushover-Dashboard: PUSHOVER_TOKEN ist das App-Token einer dort
|
||||
// angelegten Anwendung, PUSHOVER_USER der eigene User- oder Group-Key.
|
||||
// Fehlt einer der beiden, ist die Benachrichtigung still deaktiviert.
|
||||
putenv('PUSHOVER_TOKEN=');
|
||||
putenv('PUSHOVER_USER=');
|
||||
|
||||
// Zwei-Faktor-Anmeldung: fuer alle Konten freiwillig einrichtbar. Auf '1'
|
||||
// gesetzt, muessen Platform-Admins (Back-Office) zwingend einen zweiten
|
||||
// Faktor haben - erst umlegen, nachdem die eigene Einrichtung getestet ist.
|
||||
putenv('APP_REQUIRE_2FA_FOR_ADMINS=0');
|
||||
|
||||
+97
-10
@@ -3,6 +3,7 @@
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/tenant-logo.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -19,6 +20,11 @@ if (!$hasAccess) {
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'pdf_export')) {
|
||||
http_response_code(403);
|
||||
exit('Der Ausdruck der Kaffeeliste ist für euren Zugang nicht freigeschaltet.');
|
||||
}
|
||||
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
$windowDays = $settings['sheet_window_days'] ?? 100;
|
||||
$markPrice = number_format(($settings['mark_price_cents'] ?? 20) / 100, 2, ',', '.');
|
||||
@@ -29,9 +35,14 @@ $watermarkText = trim((string)($settings['pdf_watermark_text'] ?? ''));
|
||||
$footerText = trim((string)($settings['pdf_footer_text'] ?? ''));
|
||||
$watermarkLogoPath = tenant_logo_path((string)($settings['pdf_watermark_logo'] ?? '')) ?? '';
|
||||
|
||||
// Ab dieser Mitgliederzahl wird auf zwei Seiten (Vorder-/Rueckseite) statt
|
||||
// einer einzelnen Seite gedruckt.
|
||||
define('EXPORT_MULTI_PAGE_THRESHOLD', 50);
|
||||
// Hinweis auf die Kaffeeliste: im Ausdruck nach derselben Regel wie in der
|
||||
// Web-Ansicht - im kostenlosen Tarif fest, sonst nach Mandant-Einstellung.
|
||||
require_once __DIR__ . '/app/plan-watermark.php';
|
||||
$planWatermarkLabel = plan_watermark_visible($pdo, $tenantId) ? plan_watermark_label() : '';
|
||||
|
||||
// Vorder-/Rueckseite erst oberhalb der gemeinsamen Schwelle aus app/ledger.php
|
||||
// (LEDGER_SINGLE_PAGE_MAX_MEMBERS): bis einschliesslich 50 Personen bleibt der
|
||||
// Ausdruck bewusst einseitig.
|
||||
// TCPDF's writeHTML() ignoriert CSS-Zeilenhoehen/-innenabstaende auf
|
||||
// Tabellenzeilen vollstaendig (getestet: weder "height" auf <tr> noch
|
||||
// "padding" auf <td> haben irgendeinen Einfluss auf die gerenderte Zeilen-
|
||||
@@ -54,17 +65,24 @@ define('EXPORT_HARD_MIN_ROW_HEIGHT_MM', 2.0);
|
||||
// Overhead je Zeile. Ohne Beruecksichtigung summiert sich das ueber viele
|
||||
// Zeilen zu mehreren mm und kann einen ungewollten Seitenumbruch ausloesen.
|
||||
define('EXPORT_ROW_OVERHEAD_MM', 0.2);
|
||||
// Groesse der Marke unten rechts (Hinweis auf die Kaffeeliste): Faktor auf
|
||||
// alle Masse in MyCustomPDFWithWatermark::planWatermark(). Schrift und Tasse
|
||||
// wachsen gemeinsam, damit die Proportionen stimmen.
|
||||
define('PLAN_WATERMARK_SCALE', 1.5);
|
||||
// Platz, den die Marke am Seitenfuss belegt - inklusive Dampffaehnchen und
|
||||
// etwas Luft zur Tabelle.
|
||||
define('PLAN_WATERMARK_BAND_MM', 8.0);
|
||||
|
||||
$allActive = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$memberCount = count($allActive);
|
||||
$isMultiPage = $memberCount >= EXPORT_MULTI_PAGE_THRESHOLD;
|
||||
$isMultiPage = ledger_uses_two_sided_sheet($memberCount);
|
||||
|
||||
/**
|
||||
* Verfuegbare Hoehe (mm) fuer die Datenzeilen einer A4-Seite: Gesamthoehe
|
||||
* minus oberem/unterem Rand, Titelzeile, Spaltenkopf und optional
|
||||
* reservierter Fusszeile bzw. Vorder-/Rueckseiten-Hinweiszeile.
|
||||
*/
|
||||
function export_page_row_budget_mm(bool $hasFooter, bool $hasSeitenHinweis): float
|
||||
function export_page_row_budget_mm(bool $hasFooter, bool $hasSeitenHinweis, bool $hasPlanWatermark = false): float
|
||||
{
|
||||
// Kleiner zusaetzlicher Sicherheitspuffer obendrauf (der zeilenweise
|
||||
// Cell()-Overhead wird bereits separat in export_compute_layout()
|
||||
@@ -72,6 +90,10 @@ function export_page_row_budget_mm(bool $hasFooter, bool $hasSeitenHinweis): flo
|
||||
$budget = 297.0 - 5.0 - 5.0 - EXPORT_TITLE_ROW_MM - EXPORT_HEADER_ROW_MM - 2.0;
|
||||
if ($hasFooter) {
|
||||
$budget -= 12.0;
|
||||
} elseif ($hasPlanWatermark) {
|
||||
// Der Hinweis auf die Kaffeeliste braucht weniger Platz als eine
|
||||
// Fusszeile - steht beides, deckt deren Band ihn mit ab.
|
||||
$budget -= PLAN_WATERMARK_BAND_MM;
|
||||
}
|
||||
if ($hasSeitenHinweis) {
|
||||
$budget -= EXPORT_HINT_ROW_MM;
|
||||
@@ -113,6 +135,7 @@ class MyCustomPDFWithWatermark extends TCPDF {
|
||||
public string $watermarkText = '';
|
||||
public string $watermarkLogoPath = '';
|
||||
public string $footerText = '';
|
||||
public string $planWatermarkLabel = '';
|
||||
|
||||
public function Header() {
|
||||
$hasLogo = $this->watermarkLogoPath !== '' && is_file($this->watermarkLogoPath);
|
||||
@@ -157,16 +180,75 @@ class MyCustomPDFWithWatermark extends TCPDF {
|
||||
}
|
||||
|
||||
public function Footer() {
|
||||
if (trim($this->footerText) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trim($this->footerText) !== '') {
|
||||
$this->SetY(-15);
|
||||
$this->SetFont('helvetica', '', 8);
|
||||
$this->SetTextColor(120, 120, 120);
|
||||
$this->Cell(0, 10, $this->footerText, 0, 0, 'C');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
}
|
||||
|
||||
$this->planWatermark();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinweis auf die Kaffeeliste unten rechts - dasselbe Zeichen wie in der
|
||||
* Web-Ansicht, nur fuer den Ausdruck. Das Tassen-Symbol wird mit TCPDFs
|
||||
* eigenen Zeichenbefehlen gemalt statt als SVG oder Bild eingebettet:
|
||||
* ImageSVG braucht die XML-Extension, ein Rasterbild braucht GD - beides
|
||||
* ist auf einem Webspace nicht garantiert, gezeichnete Linien dagegen
|
||||
* funktionieren immer.
|
||||
*/
|
||||
protected function planWatermark(): void
|
||||
{
|
||||
if (trim($this->planWatermarkLabel) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Alle Masse haengen an diesem Faktor, damit Schrift und Tasse
|
||||
// gemeinsam wachsen und die Marke ihre Proportionen behaelt. 1.0 war
|
||||
// die erste, zu kleine Fassung.
|
||||
$skala = PLAN_WATERMARK_SCALE;
|
||||
|
||||
$rechterRand = $this->getPageWidth() - 5.0;
|
||||
// Text und Symbol teilen sich eine gedachte Zeile: Cell() zentriert
|
||||
// den Schriftzug zuverlaessig in deren Hoehe, die Tasse wird auf
|
||||
// dieselbe Mittellinie gezeichnet. Text() waere hier die schlechtere
|
||||
// Wahl - sein y-Bezugspunkt haengt an Schriftmetriken und ergab in
|
||||
// der Probe einen um Millimeter versetzten Schriftzug.
|
||||
$zeilenHoehe = 4.0 * $skala;
|
||||
$zeileOben = $this->getPageHeight() - 5.0 - $zeilenHoehe;
|
||||
$mitte = $zeileOben + ($zeilenHoehe / 2);
|
||||
|
||||
$this->SetFont('helvetica', '', 7 * $skala);
|
||||
$this->SetTextColor(130, 130, 130);
|
||||
$textBreite = $this->GetStringWidth($this->planWatermarkLabel);
|
||||
|
||||
$tasseBreite = 2.6 * $skala;
|
||||
$tasseHoehe = 2.2 * $skala;
|
||||
$abstand = 1.0 * $skala;
|
||||
$henkelBreite = 1.0 * $skala;
|
||||
$tasseX = $rechterRand - $textBreite - $abstand - $tasseBreite - $henkelBreite;
|
||||
$tasseY = $mitte - ($tasseHoehe / 2);
|
||||
|
||||
$this->SetDrawColor(130, 130, 130);
|
||||
$this->SetLineWidth(0.2 * $skala);
|
||||
|
||||
// Tassenkoerper: nur unten abgerundet ('0011' = unten rechts/links).
|
||||
$this->RoundedRect($tasseX, $tasseY, $tasseBreite, $tasseHoehe, 0.7 * $skala, '0011', 'D');
|
||||
// Henkel: halber Bogen an der rechten Seite.
|
||||
$this->Ellipse($tasseX + $tasseBreite, $mitte, 0.8 * $skala, 0.6 * $skala, 0, 270, 90, 'D');
|
||||
// Zwei Dampffaehnchen ueber der Tasse.
|
||||
$this->Line($tasseX + (0.7 * $skala), $tasseY - (1.1 * $skala), $tasseX + (0.7 * $skala), $tasseY - (0.4 * $skala));
|
||||
$this->Line($tasseX + (1.7 * $skala), $tasseY - (1.1 * $skala), $tasseX + (1.7 * $skala), $tasseY - (0.4 * $skala));
|
||||
|
||||
$this->SetXY($rechterRand - $textBreite, $zeileOben);
|
||||
$this->Cell($textBreite, $zeilenHoehe, $this->planWatermarkLabel, 0, 0, 'R');
|
||||
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->SetDrawColor(0, 0, 0);
|
||||
$this->SetLineWidth(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +272,11 @@ function export_render_page(
|
||||
int $adminRowHeightPx,
|
||||
?string $seitenHinweis
|
||||
): void {
|
||||
$rowBudgetMm = export_page_row_budget_mm(trim($pdf->footerText) !== '', $seitenHinweis !== null);
|
||||
$rowBudgetMm = export_page_row_budget_mm(
|
||||
trim($pdf->footerText) !== '',
|
||||
$seitenHinweis !== null,
|
||||
trim($pdf->planWatermarkLabel) !== ''
|
||||
);
|
||||
[$targetRows, $rowHeight] = export_compute_layout(count($teilnehmer), $showEmptyRows, $adminRowHeightPx, $rowBudgetMm);
|
||||
|
||||
// Titelzeile: Name/Guthaben-Breite + Strichebreite, Hintergrundfarbe je nach Seite.
|
||||
@@ -255,6 +341,7 @@ $pdf = new MyCustomPDFWithWatermark(PDF_PAGE_ORIENTATION, 'mm', 'A4', true, 'UTF
|
||||
$pdf->watermarkText = $watermarkText;
|
||||
$pdf->watermarkLogoPath = $watermarkLogoPath;
|
||||
$pdf->footerText = $footerText;
|
||||
$pdf->planWatermarkLabel = $planWatermarkLabel;
|
||||
$pdf->SetHeaderData("", 0, "Kaffeestrichliste", "");
|
||||
$pdf->SetPrintFooter(true);
|
||||
$pdf->SetMargins('5', '5', '5');
|
||||
|
||||
@@ -4,6 +4,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/faq.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -37,6 +38,12 @@ if (!$hasAccess) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'faq')) {
|
||||
echo app_feature_notice_html('faq');
|
||||
include "footer.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
$meldung = null;
|
||||
$fehler = null;
|
||||
$bearbeitenId = null;
|
||||
|
||||
+63
-1
@@ -30,6 +30,42 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
$saasHasMultipleTenants = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Gesperrte Funktionen erscheinen gar nicht erst im Menue - ein Menuepunkt,
|
||||
// der nur zu einer Absage fuehrt, ist schlechter als keiner. Geprueft werden
|
||||
// beide Ebenen: die Freischaltung durch den Betreiber und der eigene Schalter
|
||||
// des Mandanten in den Mandant-Einstellungen (z. B. "PayPal anbieten").
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
$saasNavTenantId = $saasNavUser !== null ? (int)$saasNavUser['tenant_id'] : 0;
|
||||
$saasNavFeature = static function (string $key) use ($saasNavTenantId): bool {
|
||||
if ($saasNavTenantId <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return app_feature_available(app_db_pdo(), $saasNavTenantId, $key);
|
||||
};
|
||||
|
||||
// Sonderfall PayPal: abgeschaltet heisst normalerweise unsichtbar - solange
|
||||
// aber noch Zahlungen unzugeordnet in der Warteschlange liegen, bleibt der
|
||||
// Menuepunkt stehen, sonst kaeme niemand mehr an sie heran.
|
||||
$saasNavPaypalInbox = true;
|
||||
if ($saasNavTenantId > 0) {
|
||||
require_once __DIR__ . '/app/paypal-inbox.php';
|
||||
$saasNavPaypalInbox = paypal_inbox_accessible(app_db_pdo(), $saasNavTenantId);
|
||||
}
|
||||
|
||||
// Das Back-Office war bisher nur ueber die direkte URL erreichbar. Es ist die
|
||||
// Zentralstelle des Betreibers und gehoert deshalb ins Menue - aber nur fuer
|
||||
// Platform-Admins, nicht fuer Mandanten-Rollen.
|
||||
$saasNavIsPlatformAdmin = false;
|
||||
if ($saasNavUser !== null) {
|
||||
try {
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
$saasNavIsPlatformAdmin = app_is_platform_admin(app_db_pdo(), (int)$saasNavUser['user_id']);
|
||||
} catch (Throwable $e) {
|
||||
$saasNavIsPlatformAdmin = false;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -37,7 +73,9 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<ul class="menu-group-items">
|
||||
<li><a href="index.php">Meine Kaffeeliste</a></li>
|
||||
<li><a href="anleitung.php">Anleitung</a></li>
|
||||
<?php if ($saasNavFeature('faq')): ?>
|
||||
<li><a href="faq.php">FAQ Kaffeeliste</a></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php if ($canUseVerwaltungGroup): ?>
|
||||
@@ -45,11 +83,15 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<span class="menu-group-label">Verwaltung</span>
|
||||
<ul class="menu-group-items">
|
||||
<li><a href="einzahlung.php">Einzahlung eintragen</a></li>
|
||||
<?php if ($saasNavPaypalInbox): ?>
|
||||
<li><a href="paypal-zuordnung.php">PayPal-Zahlungen</a></li>
|
||||
<?php endif; ?>
|
||||
<li><a href="stricheintragen.php">Striche eintragen</a></li>
|
||||
<li><a href="kaffeeliste.php">Kaffeeliste anzeigen</a></li>
|
||||
<li><a href="ledger-preview.php">Journal-Vorschau</a></li>
|
||||
<?php if ($saasNavFeature('year_closing')): ?>
|
||||
<li><a href="jahresauswertung.php">Jahresabschluss</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if ($saasCanManageMembersNav): ?>
|
||||
<li><a href="mitarbeiterverwalten.php">Mitglieder verwalten</a></li>
|
||||
<li><a href="hinweise.php">Hinweise verwalten</a></li>
|
||||
@@ -74,6 +116,14 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<li><a href="logout.php">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($saasNavIsPlatformAdmin): ?>
|
||||
<li>
|
||||
<span class="menu-group-label">Betreiber</span>
|
||||
<ul class="menu-group-items">
|
||||
<li><a href="backoffice.php">Back-Office</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -83,7 +133,7 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<!-- Footer -->
|
||||
|
||||
<footer id="footer">
|
||||
<p class="copyright">© Copyright: Clemens Creutzburg All rights reserved. Design: <a href="http://html5up.net">HTML5 UP</a> · <a href="impressum.php">Impressum</a> · <a href="agb.php">AGB</a> · <a href="datenschutz.php">Datenschutz</a> · <a href="preise.php">Preise</a></p>
|
||||
<p class="copyright"><a href="impressum.php">Impressum</a> · <a href="agb.php">AGB</a> · <a href="datenschutz.php">Datenschutz</a> · <a href="avv.php">AVV</a> · <a href="widerruf.php">Widerrufsbelehrung</a> · <a href="widerrufen.php">Vertrag widerrufen</a> · <a href="preise.php">Preise</a> · <a href="kuendigen.php"><b>Verträge hier kündigen</b></a><?php $footerCookieSettings = app_analytics_footer_settings_button_html(); echo $footerCookieSettings !== '' ? ' · ' . $footerCookieSettings : ''; ?></p>
|
||||
|
||||
</footer>
|
||||
|
||||
@@ -92,7 +142,19 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Wasserzeichen des kostenlosen Tarifs: kleine Marke unten rechts, die auf
|
||||
// die Kaffeeliste verweist. Zahlende Mandanten sehen sie nicht.
|
||||
if ($saasNavTenantId > 0) {
|
||||
require_once __DIR__ . '/app/plan-watermark.php';
|
||||
if (plan_watermark_visible(app_db_pdo(), $saasNavTenantId)) {
|
||||
echo plan_watermark_html();
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Scripts -->
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
+18
-2
@@ -11,12 +11,28 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/app.css" />
|
||||
</head>
|
||||
<body class="is-preload">
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/app/ledger.php';
|
||||
require_once __DIR__ . '/app/notices.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
require_once __DIR__ . '/app/branding.php';
|
||||
|
||||
// Eigenes Design des Mandanten: die Akzentfarbe wird als kleines Stylesheet
|
||||
// direkt hier eingebettet - eine eigene CSS-Datei je Mandant waere ein
|
||||
// zusaetzlicher Request fuer wenige Zeilen. Die Farbe ist als Hex-Wert
|
||||
// validiert (saas_update_tenant_settings), kann also nicht aus der
|
||||
// Style-Deklaration ausbrechen.
|
||||
$headerBranding = app_branding_for_current_user(app_db_pdo());
|
||||
if ($headerBranding['color'] !== '') {
|
||||
echo "\t\t<style>\n" . app_branding_css($headerBranding['color']) . "\t\t</style>\n";
|
||||
}
|
||||
|
||||
?>
|
||||
</head>
|
||||
<body class="is-preload">
|
||||
<?php
|
||||
|
||||
// Aktuellen Hinweis fuer den passenden Mandanten abrufen: eingeloggte
|
||||
// SaaS-Nutzer sehen den Hinweis ihres Mandanten, der Legacy-/Dev-Fallback
|
||||
|
||||
+17
-1
@@ -1,2 +1,18 @@
|
||||
<?php
|
||||
// Reserved for an optional header line.
|
||||
|
||||
// Kopfzeile des Mandanten: zeigt das hinterlegte Logo, wenn der Betreiber das
|
||||
// eigene Design freigeschaltet hat und der Kunde eines hochgeladen hat.
|
||||
// Ohne Logo bleibt die Zeile komplett aus - eine leere Flaeche waere
|
||||
// schlechter als gar keine.
|
||||
|
||||
require_once __DIR__ . '/app/branding.php';
|
||||
|
||||
$headerlineBranding = app_branding_for_current_user(app_db_pdo());
|
||||
|
||||
if ($headerlineBranding['logo_url'] !== '') {
|
||||
echo '<div class="tenant-brand-line">'
|
||||
. '<img src="' . htmlspecialchars($headerlineBranding['logo_url'], ENT_QUOTES, 'UTF-8') . '"'
|
||||
. ' alt="' . htmlspecialchars($headerlineBranding['tenant_name'], ENT_QUOTES, 'UTF-8') . '"'
|
||||
. ' class="tenant-brand-logo">'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
+22
-21
@@ -1,4 +1,8 @@
|
||||
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
|
||||
<?php
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
app_record_public_page_view('imprint');
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
@@ -7,6 +11,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
@@ -29,21 +34,25 @@
|
||||
30655 Hannover<br>
|
||||
Deutschland
|
||||
</p>
|
||||
<p>Geschäftsbezeichnung: CTB-IT</p>
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
<p>E-Mail: <a href="mailto:info@ctb-it.de">info@ctb-it.de</a></p>
|
||||
<p>
|
||||
E-Mail: <a href="mailto:<?php echo htmlspecialchars(app_legal_email(), ENT_QUOTES, 'UTF-8'); ?>"><?php echo htmlspecialchars(app_legal_email(), ENT_QUOTES, 'UTF-8'); ?></a><br>
|
||||
Ticketsystem: <a href="<?php echo htmlspecialchars(app_legal_ticket_url(), ENT_QUOTES, 'UTF-8'); ?>"><?php echo htmlspecialchars(app_legal_ticket_url(), ENT_QUOTES, 'UTF-8'); ?></a><br>
|
||||
<?php if (app_legal_phone() !== ''): ?>
|
||||
Telefon: <a href="tel:<?php echo htmlspecialchars(preg_replace('/[^+0-9]/', '', app_legal_phone()) ?? '', ENT_QUOTES, 'UTF-8'); ?>"><?php echo htmlspecialchars(app_legal_phone(), ENT_QUOTES, 'UTF-8'); ?></a>
|
||||
<?php else: ?>
|
||||
<strong>Telefonnummer vor Produktivfreigabe über <code>LEGAL_PHONE</code> ergänzen.</strong>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p>Das Ticketsystem dokumentiert Inhalt, Datum und Uhrzeit der Korrespondenz und wird werktags regelmäßig bearbeitet.</p>
|
||||
|
||||
<h2>Umsatzsteuer</h2>
|
||||
<p>Gemäß § 19 UStG (Kleinunternehmerregelung) wird keine Umsatzsteuer
|
||||
berechnet und ausgewiesen.</p>
|
||||
<p>Aufgrund der Kleinunternehmerregelung gemäß § 19 UStG sind die Umsätze von der Umsatzsteuer befreit.</p>
|
||||
|
||||
<h2>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
|
||||
<p>Clemens Creutzburg, Anschrift wie oben.</p>
|
||||
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die
|
||||
Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch
|
||||
keine Gewähr übernehmen.</p>
|
||||
<h2>Verbraucherstreitbeilegung</h2>
|
||||
<p>Wir sind nicht verpflichtet und derzeit nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>
|
||||
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>Diese App enthält gegebenenfalls Links zu externen Websites Dritter, auf
|
||||
@@ -51,22 +60,14 @@
|
||||
Inhalte auch keine Gewähr übernehmen. Zum Zeitpunkt der Verlinkung waren keine
|
||||
Rechtsverstöße erkennbar.</p>
|
||||
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>Die durch die Betreiber erstellten Inhalte und Werke auf diesen Seiten
|
||||
unterliegen dem deutschen Urheberrecht. Beiträge Dritter sind als solche
|
||||
gekennzeichnet.</p>
|
||||
|
||||
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="public-footer-links">
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
<a href="preise.php">Preise</a>
|
||||
</footer>
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
@@ -15,6 +15,7 @@ if ($appHost !== null && $requestHost !== strtolower($appHost)) {
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -106,6 +107,12 @@ function dashboard_record_self_entry(PDO $pdo, array $participant, array $settin
|
||||
return ['ok' => false, 'error' => 'Die eigene Stricherfassung ist aktuell deaktiviert.'];
|
||||
}
|
||||
|
||||
// Zweite, uebergeordnete Huerde: der Betreiber kann die Funktion fuer den
|
||||
// ganzen Mandanten sperren, unabhaengig von dessen eigener Einstellung.
|
||||
if (!app_feature_enabled($pdo, (int)$participant['tenant_id'], 'self_entry')) {
|
||||
return ['ok' => false, 'error' => 'Die eigene Stricherfassung ist für euren Zugang nicht freigeschaltet.'];
|
||||
}
|
||||
|
||||
$marks = filter_var($marksValue, FILTER_VALIDATE_INT);
|
||||
if ($marks !== 1 && $marks !== 2) {
|
||||
return ['ok' => false, 'error' => 'Bitte einen oder zwei Striche auswählen.'];
|
||||
@@ -223,7 +230,8 @@ if ($hasAccess && $participant !== null && $settings !== null) {
|
||||
$balanceCents = (int)$participant['balance_cents'];
|
||||
$balance = dashboard_money($balanceCents);
|
||||
$currentName = dashboard_current_name($saasUser, $participant);
|
||||
$selfEntryEnabled = (int)$settings['self_entry_enabled'] === 1;
|
||||
$selfEntryEnabled = (int)$settings['self_entry_enabled'] === 1
|
||||
&& app_feature_enabled($pdo, $tenantId, 'self_entry');
|
||||
$paypalEnabled = (int)$settings['paypal_enabled'] === 1
|
||||
&& trim((string)$settings['paypal_url_template']) !== '';
|
||||
$cashEnabled = (int)$settings['cash_enabled'] === 1;
|
||||
|
||||
+12
-1
@@ -4,6 +4,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/saas-mail.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -16,6 +17,14 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktion: $hasAccess faellt zurueck, damit auch die
|
||||
// POST-Verarbeitung weiter unten nicht mehr laeuft.
|
||||
$gesperrteFunktion = null;
|
||||
if ($hasAccess && !app_feature_enabled($pdo, $tenantId, 'year_closing')) {
|
||||
$gesperrteFunktion = 'year_closing';
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
$jahr = (int)date('Y');
|
||||
$fehler = null;
|
||||
$ergebnisse = null;
|
||||
@@ -181,7 +190,9 @@ include "nav.php";
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<?php if ($gesperrteFunktion !== null): ?>
|
||||
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
|
||||
<?php elseif (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -35,21 +36,25 @@ if ($hasAccess) {
|
||||
<h2>Aktive Mitarbeiter mit Gesamtstand</h2>
|
||||
<br>
|
||||
<ul class="actions">
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'pdf_export')): ?>
|
||||
<li>
|
||||
<form action="exportKaffeeliste.php" method="get">
|
||||
<button type="submit">Kaffeeliste exportieren</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<form action="letzteneintraege.php" method="get">
|
||||
<button type="submit">Letzten Einträge</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'csv_import')): ?>
|
||||
<li>
|
||||
<form action="csvupload.php" method="get">
|
||||
<button type="submit">CSV Upload</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
|
||||
<br>
|
||||
@@ -85,9 +90,11 @@ if ($hasAccess) {
|
||||
</table>
|
||||
<br><br>
|
||||
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'mail_dispatch')): ?>
|
||||
<form action="mailversenden.php" method="get">
|
||||
<button type="submit">Info-Mail versenden</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
} else {
|
||||
echo "<h2>Kein Zugriff</h2>";
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
require_once __DIR__ . '/app/totp.php';
|
||||
|
||||
$user = saas_require_login();
|
||||
$devVerificationToken = null;
|
||||
$contractMailFailed = !empty($_SESSION['saas_contract_mail_failed']);
|
||||
$verificationMailFailed = !empty($_SESSION['saas_verification_mail_failed']);
|
||||
unset($_SESSION['saas_contract_mail_failed'], $_SESSION['saas_verification_mail_failed']);
|
||||
if (saas_should_show_auth_links() && !empty($_SESSION['saas_dev_email_verification_token'])) {
|
||||
$devVerificationToken = (string)$_SESSION['saas_dev_email_verification_token'];
|
||||
unset($_SESSION['saas_dev_email_verification_token']);
|
||||
@@ -44,6 +49,16 @@ include 'nav.php';
|
||||
<div class="hint-box success"><p>Das Kundenkonto wurde angelegt.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($contractMailFailed): ?>
|
||||
<div class="hint-box error">
|
||||
<p>Die Vertragsbestätigung konnte nicht per E-Mail versendet werden. Bitte speichere jetzt <a href="legal/agb-<?php echo saas_html(APP_TERMS_VERSION); ?>.txt" download>die AGB</a>, <a href="legal/avv-<?php echo saas_html(APP_DPA_VERSION); ?>.txt" download>den AVV</a> und bei privater Nutzung <a href="legal/widerruf-<?php echo saas_html(APP_WITHDRAWAL_VERSION); ?>.txt" download>die Widerrufsbelehrung</a>. Falls der Fehler bleibt, nutze bitte das <a href="<?php echo saas_html(app_legal_ticket_url()); ?>">Ticketsystem</a>.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($verificationMailFailed): ?>
|
||||
<div class="hint-box error"><p>Die Verifizierungs-E-Mail konnte nicht versendet werden. Bitte versuche den Versand unten erneut oder nutze das <a href="<?php echo saas_html(app_legal_ticket_url()); ?>">Ticketsystem</a>.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($devVerificationToken !== null): ?>
|
||||
<div class="hint-box success">
|
||||
<p>Der Verifizierungslink wurde vorbereitet.</p>
|
||||
@@ -76,6 +91,11 @@ include 'nav.php';
|
||||
<th>E-Mail bestätigt</th>
|
||||
<td><?php echo $user['email_verified_at'] !== null ? 'ja' : 'nein'; ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Zwei-Faktor-Anmeldung</th>
|
||||
<td><?php echo app_totp_is_active(app_db_pdo(), (int)$user['user_id']) ? 'aktiv' : 'nicht eingerichtet'; ?>
|
||||
– <a href="zwei-faktor-einrichten.php">verwalten</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if ($user['email_verified_at'] === null): ?>
|
||||
@@ -124,7 +144,9 @@ include 'nav.php';
|
||||
<?php if (saas_can_manage_tenant_settings($user)): ?>
|
||||
<ul class="actions">
|
||||
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
|
||||
<?php if (app_feature_enabled(app_db_pdo(), (int)$user['tenant_id'], 'data_export')): ?>
|
||||
<li><a href="datenexport.php" class="button">Datenexport</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if (saas_user_has_role('owner', $user)): ?>
|
||||
<li><a href="mandant-loeschen.php" class="button">Mandant löschen</a></li>
|
||||
<?php endif; ?>
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/app/database.php';
|
||||
require_once __DIR__ . '/app/legal-requests.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
app_start_session();
|
||||
$h = static fn(mixed $value): string => htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||
$stage = (string)($_POST['stage'] ?? 'form');
|
||||
$postedRequestedEnd = (string)($_POST['requested_end'] ?? '');
|
||||
if ($postedRequestedEnd === '') {
|
||||
$postedRequestedEnd = (string)($_POST['end_choice'] ?? 'earliest') === 'specific'
|
||||
? (string)($_POST['requested_end_date'] ?? '')
|
||||
: 'earliest';
|
||||
}
|
||||
$values = [
|
||||
'requester_name' => trim((string)($_POST['requester_name'] ?? '')),
|
||||
'requester_email' => trim((string)($_POST['requester_email'] ?? '')),
|
||||
'contract_reference' => strtolower(trim((string)($_POST['contract_reference'] ?? ''))),
|
||||
'cancellation_kind' => (string)($_POST['cancellation_kind'] ?? 'ordinary'),
|
||||
'request_reason' => trim((string)($_POST['request_reason'] ?? '')),
|
||||
'requested_end' => $postedRequestedEnd,
|
||||
];
|
||||
$errors = [];
|
||||
$stored = null;
|
||||
$processing = null;
|
||||
$mailStatus = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
if ($stage === 'form') {
|
||||
// Die Werte aus der Prüfseite werden wieder im Formular angezeigt.
|
||||
} else {
|
||||
$errors = app_validate_legal_request($values, 'cancellation');
|
||||
}
|
||||
if ($stage !== 'form' && $errors !== [] ) {
|
||||
$stage = 'form';
|
||||
} elseif ($stage === 'submit') {
|
||||
if (!app_rate_limit_check($pdo, 'legal_request_ip:' . app_client_ip(), 5, 3600)) {
|
||||
$errors[] = 'Zu viele Erklärungen von diesem Anschluss. Bitte nutze alternativ E-Mail oder das Ticketsystem.';
|
||||
$stage = 'form';
|
||||
} else {
|
||||
$input = $values + [
|
||||
'request_type' => 'cancellation',
|
||||
'metadata' => ['cancellation_kind' => $values['cancellation_kind']],
|
||||
];
|
||||
$stored = app_store_legal_request($pdo, $input);
|
||||
$processing = app_process_cancellation_request($pdo, $stored, $input);
|
||||
$mailStatus = app_send_legal_request_confirmations($input, $stored, $processing);
|
||||
$stage = 'complete';
|
||||
}
|
||||
} elseif ($stage !== 'form') {
|
||||
$stage = 'review';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>Vertrag kündigen – Kaffeeliste</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="assets/css/main.css">
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
<section class="public-section public-legal">
|
||||
<nav class="public-nav" aria-label="Hauptnavigation"><strong><a href="landing.php">Kaffeeliste</a></strong></nav>
|
||||
<header class="major"><h1>Vertrag kündigen</h1></header>
|
||||
|
||||
<?php if ($stage === 'complete' && $stored !== null && $processing !== null): ?>
|
||||
<div class="hint-box success">
|
||||
<h2>Kündigung eingegangen</h2>
|
||||
<p>Referenz: <b><?php echo $h($stored['reference_code']); ?></b><br>
|
||||
Eingang: <?php echo $h($stored['received_at']); ?> Uhr (Europe/Berlin)<br>
|
||||
Vertragsende: <?php echo $h($processing['effective_end']); ?></p>
|
||||
<p><?php echo $h($processing['processing_note']); ?></p>
|
||||
</div>
|
||||
<?php if (($mailStatus['customer_ok'] ?? false) === true): ?>
|
||||
<p>Wir haben die Eingangsbestätigung sofort an <?php echo $h($values['requester_email']); ?> gesendet.</p>
|
||||
<?php else: ?>
|
||||
<div class="hint-box error">
|
||||
<p>Die Kündigung wurde gespeichert, aber die E-Mail-Bestätigung konnte nicht versendet werden. Bitte speichere die Bestätigung jetzt über den folgenden Link und kontaktiere uns bei Bedarf über das <a href="<?php echo $h(app_legal_ticket_url()); ?>">Ticketsystem</a>.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<ul class="actions">
|
||||
<li><a class="button primary" href="rechtserklaerung-bestaetigung.php?token=<?php echo $h($stored['access_token']); ?>&download=1">Bestätigung als Textdatei speichern</a></li>
|
||||
<li><a class="button" href="landing.php">Zur Startseite</a></li>
|
||||
</ul>
|
||||
<?php elseif ($stage === 'review'): ?>
|
||||
<h2>Angaben prüfen</h2>
|
||||
<p>
|
||||
Name: <?php echo $h($values['requester_name']); ?><br>
|
||||
E-Mail: <?php echo $h($values['requester_email']); ?><br>
|
||||
Kundenkürzel: <?php echo $h($values['contract_reference']); ?><br>
|
||||
Art: <?php echo $values['cancellation_kind'] === 'extraordinary' ? 'außerordentlich' : 'ordentlich'; ?><br>
|
||||
Ende: <?php echo $values['requested_end'] === 'earliest' ? 'zum frühestmöglichen Zeitpunkt' : $h($values['requested_end']); ?>
|
||||
</p>
|
||||
<?php if ($values['request_reason'] !== ''): ?><p>Grund: <?php echo nl2br($h($values['request_reason'])); ?></p><?php endif; ?>
|
||||
<form method="post" action="kuendigen.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="stage" value="submit">
|
||||
<?php foreach ($values as $name => $value): ?>
|
||||
<input type="hidden" name="<?php echo $h($name); ?>" value="<?php echo $h($value); ?>">
|
||||
<?php endforeach; ?>
|
||||
<ul class="actions">
|
||||
<li><button type="submit" class="primary">jetzt kündigen</button></li>
|
||||
<li><button type="submit" name="stage" value="form" class="button">Angaben ändern</button></li>
|
||||
</ul>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p>Hier kannst du einen kostenlosen oder kostenpflichtigen Kaffeeliste-Vertrag ordentlich oder außerordentlich kündigen. Eine Anmeldung ist nicht erforderlich.</p>
|
||||
<?php if ($errors !== []): ?><div class="hint-box error"><?php foreach ($errors as $error): ?><p><?php echo $h($error); ?></p><?php endforeach; ?></div><?php endif; ?>
|
||||
<form method="post" action="kuendigen.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="stage" value="review">
|
||||
<label for="requester_name">Vollständiger Name</label>
|
||||
<input type="text" name="requester_name" id="requester_name" maxlength="255" value="<?php echo $h($values['requester_name']); ?>" required>
|
||||
<label for="requester_email">E-Mail für die sofortige Bestätigung</label>
|
||||
<input type="email" name="requester_email" id="requester_email" maxlength="255" value="<?php echo $h($values['requester_email']); ?>" required>
|
||||
<label for="contract_reference">Kundenkürzel / Vertragsreferenz</label>
|
||||
<input type="text" name="contract_reference" id="contract_reference" maxlength="100" value="<?php echo $h($values['contract_reference']); ?>" required>
|
||||
<fieldset>
|
||||
<legend>Art der Kündigung</legend>
|
||||
<input type="radio" name="cancellation_kind" id="ordinary" value="ordinary"<?php echo $values['cancellation_kind'] === 'ordinary' ? ' checked' : ''; ?>>
|
||||
<label for="ordinary">Ordentliche Kündigung</label>
|
||||
<input type="radio" name="cancellation_kind" id="extraordinary" value="extraordinary"<?php echo $values['cancellation_kind'] === 'extraordinary' ? ' checked' : ''; ?>>
|
||||
<label for="extraordinary">Außerordentliche Kündigung</label>
|
||||
</fieldset>
|
||||
<label for="request_reason">Kündigungsgrund (nur bei außerordentlicher Kündigung erforderlich)</label>
|
||||
<textarea name="request_reason" id="request_reason" maxlength="5000" rows="4"><?php echo $h($values['request_reason']); ?></textarea>
|
||||
<fieldset>
|
||||
<legend>Zeitpunkt der Beendigung</legend>
|
||||
<input type="radio" name="end_choice" id="end_earliest" value="earliest"<?php echo $values['requested_end'] === 'earliest' ? ' checked' : ''; ?>>
|
||||
<label for="end_earliest">Zum frühestmöglichen Zeitpunkt</label>
|
||||
<input type="radio" name="end_choice" id="end_specific" value="specific"<?php echo $values['requested_end'] !== 'earliest' ? ' checked' : ''; ?>>
|
||||
<label for="end_specific">Zu einem bestimmten Datum</label>
|
||||
<label for="requested_end_date">Gewünschtes Datum</label>
|
||||
<input type="date" name="requested_end_date" id="requested_end_date" value="<?php echo $values['requested_end'] !== 'earliest' ? $h($values['requested_end']) : ''; ?>">
|
||||
</fieldset>
|
||||
<button type="submit" class="primary">Kündigung prüfen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
<script src="assets/js/util.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+10
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/app/bootstrap.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
|
||||
app_record_public_page_view('landing');
|
||||
|
||||
// Absolute Basis-URL fuer Open-Graph/Canonical: die Landingpage laeuft auf der
|
||||
// Marketing-Domain (nicht APP_HOST), daher aus dem aktuellen Request ableiten.
|
||||
@@ -76,6 +79,7 @@ $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
||||
<link rel="preload" as="image" href="assets/images/landing-hero.png" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
<script type="application/ld+json"><?php echo json_encode($productLd, $jsonFlags); ?></script>
|
||||
<script type="application/ld+json"><?php echo json_encode($faqLd, $jsonFlags); ?></script>
|
||||
</head>
|
||||
@@ -283,9 +287,15 @@ $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
<a href="avv.php">AVV</a>
|
||||
<a href="widerruf.php">Widerrufsbelehrung</a>
|
||||
<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>
|
||||
<a href="preise.php">Preise</a>
|
||||
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||
<?php echo app_analytics_footer_settings_button_html(); ?>
|
||||
</footer>
|
||||
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
## 1. Anbieter, Geltungsbereich und Begriffe
|
||||
|
||||
Diese Allgemeinen Geschäftsbedingungen gelten für die Nutzung der webbasierten Anwendung „Kaffeeliste“ durch Unternehmer im Sinne des § 14 BGB und durch Verbraucher im Sinne des § 13 BGB.
|
||||
|
||||
Anbieter ist Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de. Weitere Kontaktmöglichkeiten stehen im [Impressum](impressum.php).
|
||||
|
||||
„Kunde“ ist die Person, die einen Kundenbereich (Mandanten) registriert. „Teilnehmer“ sind Personen, deren Kaffee- oder Getränkebuchungen der Kunde innerhalb dieses Bereichs verwaltet.
|
||||
|
||||
## 2. Vertragsschluss und Vertragstext
|
||||
|
||||
Der kostenlose Nutzungsvertrag kommt zustande, wenn der Kunde das Registrierungsformular vollständig absendet, diese AGB akzeptiert und der Anbieter den Kundenbereich freischaltet. Der Kunde kann Eingabefehler vor dem Absenden über die üblichen Formularfunktionen berichtigen. Vertragssprache ist Deutsch.
|
||||
|
||||
Ein kostenpflichtiges Abonnement kommt erst zustande, wenn der Kunde nach Anzeige des Tarifs, des monatlichen Gesamtpreises, der Laufzeit und der Kündigungsbedingungen den Bestellvorgang beim Zahlungsdienstleister Stripe mit einer eindeutig auf die Zahlungspflicht hinweisenden Schaltfläche abschließt und die Buchung bestätigt wird. Bis dahin kann der Vorgang abgebrochen oder berichtigt werden.
|
||||
|
||||
Der Anbieter protokolliert die angenommenen Versionen der Rechtstexte und übersendet dem Kunden eine Vertragsbestätigung in Textform. Der Kunde kann die aktuellen Texte außerdem jederzeit herunterladen oder ausdrucken.
|
||||
|
||||
## 3. Leistungen und technische Voraussetzungen
|
||||
|
||||
Der Anbieter stellt eine mandantenfähige Webanwendung zur Verwaltung einer internen Kaffee- oder Getränkekasse bereit. Zum Kern gehören die Verwaltung von Teilnehmern und Rollen, die Erfassung von Verbrauch und Einzahlungen, die Berechnung von Salden sowie die im gebuchten Tarif ausgewiesenen Export- und Verwaltungsfunktionen.
|
||||
|
||||
Erforderlich sind ein aktueller Browser, eine Internetverbindung und eine erreichbare E-Mail-Adresse. Die Anwendung ist für die Nutzung im Browser vorgesehen. Ein Anspruch auf eine mobile App, bestimmte Schnittstellen oder Funktionen außerhalb der veröffentlichten Leistungsbeschreibung besteht nicht.
|
||||
|
||||
Der Anbieter darf die Anwendung aktualisieren und sicherheitsbedingt ändern. Bei Verbraucherverträgen über digitale Produkte bleiben die gesetzlichen Aktualisierungs-, Bereitstellungs- und Mängelrechte, insbesondere nach §§ 327 ff. BGB, unberührt. Wesentliche, vertraglich geschuldete Funktionen werden nicht ohne sachlichen Grund entzogen.
|
||||
|
||||
## 4. Tarife, Preise und Zahlung
|
||||
|
||||
Tarifgrenzen und monatliche Endpreise stehen auf der Seite [Preise](preise.php). Wegen Anwendung der Kleinunternehmerregelung nach § 19 UStG wird keine Umsatzsteuer ausgewiesen. Es fallen keine Versandkosten an.
|
||||
|
||||
Kostenpflichtige Tarife werden monatlich im Voraus über Stripe abgerechnet. Eine automatische Hochstufung allein wegen einer höheren Teilnehmerzahl findet nicht statt. Ist die Grenze des gebuchten Tarifs erreicht, können keine weiteren aktiven Teilnehmer angelegt oder reaktiviert werden, bis der Kunde selbst einen passenden Tarif bucht oder Teilnehmer deaktiviert.
|
||||
|
||||
Ein Wechsel zwischen kostenpflichtigen Tarifen schaltet die neue Teilnehmergrenze sofort frei. Für die bereits laufende, bezahlte Abrechnungsperiode erfolgt weder eine zusätzliche zeitanteilige Belastung noch eine Gutschrift; der neue volle Monatspreis gilt ab der nächsten Verlängerung. Ein Wechsel in den kostenlosen Tarif ist eine Kündigung des kostenpflichtigen Abonnements zum Ende des laufenden Abrechnungszeitraums.
|
||||
|
||||
Gerät der Kunde in Zahlungsverzug, darf der Anbieter kostenpflichtige Zusatzfunktionen nach vorherigem Hinweis vorübergehend sperren. Gesetzliche Rechte bleiben unberührt.
|
||||
|
||||
## 5. Pflichten des Kunden
|
||||
|
||||
Der Kunde macht richtige Angaben, hält seine E-Mail-Adresse aktuell, schützt Zugangsdaten und informiert den Anbieter unverzüglich über vermuteten Missbrauch. Inhalte und Daten dürfen nicht rechtswidrig sein, Rechte Dritter verletzen, Schadsoftware enthalten oder den Dienst übermäßig belasten.
|
||||
|
||||
Wer Daten anderer Personen verwaltet, stellt eine hierfür erforderliche Rechtsgrundlage und Information der Betroffenen sicher. Soweit der Kunde Verantwortlicher im Sinne der DSGVO ist und der Anbieter in seinem Auftrag verarbeitet, gilt der elektronisch vereinbarte [Auftragsverarbeitungsvertrag](avv.php). Für ausschließlich persönliche oder familiäre Tätigkeiten kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten.
|
||||
|
||||
## 6. Verfügbarkeit, Wartung und Support
|
||||
|
||||
Der Anbieter betreibt den Dienst mit der im Verkehr üblichen Sorgfalt. Ohne gesonderte Service-Level-Vereinbarung wird keine bestimmte prozentuale Verfügbarkeit garantiert. Vorhersehbare Wartungen werden möglichst zu nutzungsarmen Zeiten vorgenommen. Kurzzeitige Einschränkungen durch Wartung, Sicherheitsmaßnahmen oder Ereignisse außerhalb des Einflussbereichs des Anbieters sind möglich.
|
||||
|
||||
Supportanfragen können per E-Mail oder über das im Impressum verlinkte Ticketsystem gestellt werden. Gesetzliche Mängelrechte werden dadurch nicht eingeschränkt.
|
||||
|
||||
## 7. Laufzeit und Kündigung
|
||||
|
||||
Der kostenlose Vertrag läuft auf unbestimmte Zeit und kann jederzeit ohne Frist gekündigt werden. Kostenpflichtige Abonnements laufen ebenfalls auf unbestimmte Zeit, haben eine monatliche Abrechnungsperiode und können jederzeit zum Ende der laufenden Abrechnungsperiode gekündigt werden. Es gibt keine darüber hinausgehende Mindestlaufzeit.
|
||||
|
||||
Verbraucher können die ständig erreichbare Funktion [Verträge hier kündigen](kuendigen.php) verwenden. Eine Kündigung in Textform per E-Mail ist ebenfalls möglich. Das Recht zur außerordentlichen Kündigung aus wichtigem Grund bleibt bestehen. Der Anbieter kann ordentlich mit einer Frist von einem Monat zum Ende der laufenden Abrechnungsperiode kündigen; bei kostenlosen Verträgen mit einer Frist von einem Monat.
|
||||
|
||||
Der Kunde sollte seinen Datenexport vor dem Vertragsende herunterladen. Nach Vertragsende wird der Zugang gesperrt; die operativen Mandantendaten werden 30 Tage später gelöscht. Eine vom Kunden im Konto ausdrücklich ausgelöste Mandantenlöschung wirkt sofort und ist nicht rückgängig zu machen. Abrechnungsunterlagen, Vertragsnachweise und offene Rechtsfälle werden nur im gesetzlich erforderlichen Umfang getrennt weiter aufbewahrt.
|
||||
|
||||
## 8. Widerrufsrecht für Verbraucher
|
||||
|
||||
Verbrauchern steht bei Fernabsatzverträgen grundsätzlich ein vierzehntägiges Widerrufsrecht zu. Einzelheiten, Musterformular und die elektronische Widerrufsfunktion stehen unter [Widerruf](widerruf.php). Verlangt ein Verbraucher ausdrücklich, dass eine kostenpflichtige Leistung vor Ablauf der Widerrufsfrist beginnt, kann er bei einem Widerruf Wertersatz für die bis dahin erbrachte Leistung schulden. Das Widerrufsrecht erlischt bei einer laufenden Dienstleistung nicht bereits durch den bloßen Leistungsbeginn.
|
||||
|
||||
## 9. Mängelrechte
|
||||
|
||||
Es gelten die gesetzlichen Mängelrechte. Verbraucher haben bei digitalen Produkten insbesondere Anspruch auf vertragsgemäße Bereitstellung und erforderliche Aktualisierungen sowie bei Mängeln die gesetzlichen Rechte auf Nacherfüllung, Vertragsbeendigung, Minderung und gegebenenfalls Schadensersatz. Garantien werden nur übernommen, wenn sie ausdrücklich als solche bezeichnet sind.
|
||||
|
||||
## 10. Haftung
|
||||
|
||||
Der Anbieter haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, nach dem Produkthaftungsgesetz, bei arglistigem Verschweigen eines Mangels und im Umfang einer ausdrücklich übernommenen Garantie.
|
||||
|
||||
Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht ist die Haftung auf den bei Vertragsschluss typischerweise vorhersehbaren Schaden begrenzt. Wesentliche Vertragspflichten sind solche, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen, soweit dies gesetzlich zulässig ist.
|
||||
|
||||
Die vorstehenden Begrenzungen gelten entsprechend zugunsten der Beschäftigten und Erfüllungsgehilfen des Anbieters. Zwingende Verbraucherrechte und Ansprüche nach den Vorschriften über digitale Produkte bleiben unberührt.
|
||||
|
||||
## 11. Änderungen dieser Bedingungen und der Leistung
|
||||
|
||||
Änderungen dieser AGB für bestehende Verträge erfolgen nur aus einem sachlichen Grund, insbesondere wegen einer Rechtsänderung, Sicherheitsanforderung oder einer notwendigen technischen Weiterentwicklung, und nur soweit das Verhältnis von Leistung und Gegenleistung nicht unangemessen zulasten des Kunden verschoben wird. Der Anbieter teilt Änderungen mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit.
|
||||
|
||||
Eine Änderung, die Hauptleistung, Preis oder wesentliche Verbraucherrechte betrifft, wird nur mit ausdrücklicher Zustimmung wirksam. Stimmt der Kunde einer notwendigen Änderung nicht zu, dürfen beide Seiten den Vertrag nach den bisherigen Bedingungen ordentlich beenden.
|
||||
|
||||
## 12. Verbraucherstreitbeilegung
|
||||
|
||||
Der Anbieter ist nicht verpflichtet und derzeit nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen. Unabhängig davon können Beschwerden über die Kontaktwege im Impressum eingereicht werden.
|
||||
|
||||
## 13. Schlussbestimmungen
|
||||
|
||||
Es gilt deutsches Recht. Bei Verbrauchern gilt diese Rechtswahl nur, soweit dadurch zwingender Schutz des Staates ihres gewöhnlichen Aufenthalts nicht entzogen wird. Das UN-Kaufrecht ist ausgeschlossen.
|
||||
|
||||
Ausschließlicher Gerichtsstand Hannover wird nur vereinbart, wenn der Kunde Kaufmann, juristische Person des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen ist oder keinen allgemeinen Gerichtsstand in Deutschland hat und die Vereinbarung gesetzlich zulässig ist. Im Übrigen gelten die gesetzlichen Gerichtsstände.
|
||||
|
||||
Sollte eine Regelung unwirksam sein, gelten an ihrer Stelle die gesetzlichen Vorschriften; der übrige Vertrag bleibt bestehen.
|
||||
|
||||
Stand: 22. August 2026
|
||||
@@ -0,0 +1,84 @@
|
||||
## Vertrag über Auftragsverarbeitung nach Art. 28 DSGVO
|
||||
|
||||
zwischen dem im Kundenkonto bezeichneten Kunden („Verantwortlicher“) und Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover („Auftragsverarbeiter“).
|
||||
|
||||
Diese Vereinbarung gilt, soweit der Kunde personenbezogene Daten anderer Personen in der Kaffeeliste in eigener Verantwortlichkeit verarbeitet. Bei einer ausschließlich persönlichen oder familiären Tätigkeit kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten; die nachfolgenden Schutz- und Vertraulichkeitspflichten gelten vertraglich dennoch.
|
||||
|
||||
## 1. Gegenstand, Dauer, Art und Zweck
|
||||
|
||||
Gegenstand ist der Betrieb der webbasierten Kaffeeliste nach dem Hauptvertrag. Die Verarbeitung beginnt mit Anlage von Daten anderer Personen und dauert bis zu deren Löschung oder dem Ende des Hauptvertrags. Sie umfasst Erheben, Erfassen, Ordnen, Speichern, Auslesen, Abfragen, Berechnen, Übermitteln an vom Kunden bestimmte Empfänger, Einschränken, Anonymisieren und Löschen.
|
||||
|
||||
Zweck ist die Verwaltung einer internen Kaffee- oder Getränkekasse, insbesondere von Teilnehmern, Verbrauchsbuchungen, Einzahlungen, Salden, Benachrichtigungen und Exporten.
|
||||
|
||||
## 2. Datenarten und betroffene Personen
|
||||
|
||||
- Stammdaten: Name, Anzeigename, optional E-Mail-Adresse und PayPal-Anzeigename
|
||||
- Nutzungs- und Buchungsdaten: Verbrauch, Einzahlungen, Salden, Zeitpunkte, Bemerkungen und Zuordnungen
|
||||
- Kommunikationsdaten: Empfänger, Betreff, Versandstatus und Fehlermeldungen von Benachrichtigungen
|
||||
- Zugangs- und Protokolldaten: Rollen, technische Kennungen und sicherheitsrelevante Aktionen
|
||||
- Betroffene: Teilnehmer, Beschäftigte, Vereinsmitglieder, Familien- oder Gruppenmitglieder sowie vom Kunden berechtigte Nutzer
|
||||
|
||||
Besondere Kategorien personenbezogener Daten nach Art. 9 DSGVO sind nicht vorgesehen. Der Kunde weist den Auftragsverarbeiter nicht an, solche Daten einzugeben.
|
||||
|
||||
## 3. Weisungen
|
||||
|
||||
Der Auftragsverarbeiter verarbeitet die Daten nur auf dokumentierte Weisung des Verantwortlichen, soweit keine gesetzliche Verpflichtung eine andere Verarbeitung verlangt. Der Hauptvertrag, die Einstellungen und die Nutzung der vorgesehenen Funktionen gelten als dokumentierte Weisungen. Weitergehende Weisungen können in Textform über die Kontaktwege im Impressum erteilt werden.
|
||||
|
||||
Hält der Auftragsverarbeiter eine Weisung für datenschutzwidrig, informiert er den Verantwortlichen unverzüglich und darf die Ausführung bis zur Klärung aussetzen. Zusätzlicher Aufwand außerhalb des vereinbarten Leistungsumfangs kann nach vorheriger Vereinbarung berechnet werden.
|
||||
|
||||
## 4. Vertraulichkeit und Personal
|
||||
|
||||
Der Auftragsverarbeiter setzt nur Personen ein, die zur Vertraulichkeit verpflichtet und angemessen über Datenschutz und Informationssicherheit unterwiesen wurden. Zugriffe werden auf das zur Aufgabenerfüllung erforderliche Maß beschränkt.
|
||||
|
||||
## 5. Technische und organisatorische Maßnahmen
|
||||
|
||||
- verschlüsselte Übertragung über TLS und HSTS auf der Produktivdomain
|
||||
- Passwörter nur als zeitgemäße, gesalzene Hashwerte; geheime Schlüssel außerhalb des Quellcodes
|
||||
- rollen- und mandantenbezogene serverseitige Zugriffskontrollen nach dem Minimalprinzip
|
||||
- Sitzungs-Cookies mit HttpOnly, Secure bei HTTPS und SameSite; CSRF-Schutz bei schreibenden Vorgängen
|
||||
- Begrenzung automatisierter Anmelde- und Rücksetzversuche sowie Protokollierung sicherheitsrelevanter Administrationsvorgänge
|
||||
- getrennte Mandantenkennungen, Fremdschlüssel und automatisierte Isolationstests
|
||||
- regelmäßige Sicherheitsaktualisierungen, eingeschränkte Administrationszugänge und gesicherte Betriebszugänge
|
||||
- regelmäßige verschlüsselte oder zugriffsgeschützte Datensicherungen nach dem dokumentierten Backupkonzept sowie Wiederherstellungstests
|
||||
- Lösch-, Export- und Bereinigungskonzepte für Mandantendaten, Protokolle und temporäre Daten
|
||||
- Verfahren zur Behandlung von Sicherheitsvorfällen und zur Bewertung meldepflichtiger Datenschutzverletzungen
|
||||
|
||||
Der Verantwortliche prüft diese Maßnahmen im Hinblick auf sein Risiko und informiert den Auftragsverarbeiter vor Vertragsbeginn, wenn ergänzende Maßnahmen erforderlich sind. Änderungen bleiben zulässig, sofern das Schutzniveau nicht unterschritten wird.
|
||||
|
||||
## 6. Unterauftragsverarbeiter
|
||||
|
||||
Der Verantwortliche erteilt die allgemeine Genehmigung zum Einsatz folgender Unterauftragsverarbeiter:
|
||||
|
||||
- netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland – Rechenzentrum, Hosting, Speicher, Backup-Infrastruktur und Mailtransport; Verarbeitungsort Deutschland beziehungsweise Europäische Union
|
||||
|
||||
Stripe und das interne Abrechnungssystem Dolibarr erhalten grundsätzlich nur Vertrags-, Rechnungs- und Zahlungsdaten des Kunden, nicht die im Auftrag verwalteten Teilnehmer- und Kasseninhalte. Soweit im Einzelfall doch Auftragsdaten an einen weiteren Dienst übermittelt werden sollen, wird dieser vorab als Unterauftragsverarbeiter einbezogen.
|
||||
|
||||
Über beabsichtigte Änderungen der Unterauftragsverarbeiter informiert der Auftragsverarbeiter mindestens 30 Tage vorher in Textform. Der Verantwortliche kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Ist keine zumutbare Alternative möglich, kann jede Partei den betroffenen Dienst zum Änderungszeitpunkt kündigen.
|
||||
|
||||
Der Auftragsverarbeiter verpflichtet Unterauftragsverarbeiter nach Art. 28 Abs. 4 DSGVO mindestens zu gleichwertigen Pflichten und bleibt gegenüber dem Verantwortlichen für deren Erfüllung verantwortlich.
|
||||
|
||||
## 7. Unterstützung des Verantwortlichen
|
||||
|
||||
Der Auftragsverarbeiter unterstützt den Verantwortlichen unter Berücksichtigung der Art der Verarbeitung angemessen bei Betroffenenanfragen, Datenschutz-Folgenabschätzungen, Konsultationen mit Aufsichtsbehörden und bei der Erfüllung der Pflichten aus Art. 32 bis 36 DSGVO. Geht eine Betroffenenanfrage unmittelbar ein, leitet er sie ohne eigene inhaltliche Entscheidung an den Verantwortlichen weiter, soweit der Anbieter nicht für eigene Datenverarbeitungen selbst verantwortlich ist.
|
||||
|
||||
## 8. Datenschutzverletzungen
|
||||
|
||||
Der Auftragsverarbeiter informiert den Verantwortlichen unverzüglich, nachdem ihm eine Verletzung des Schutzes von Auftragsdaten bekannt wurde. Die Meldung enthält, soweit verfügbar, Art und Umfang des Vorfalls, betroffene Daten und Personen, wahrscheinliche Folgen, ergriffene oder vorgeschlagene Maßnahmen sowie einen Kontaktpunkt. Fehlende Angaben werden nachgereicht.
|
||||
|
||||
## 9. Nachweise und Kontrollen
|
||||
|
||||
Der Auftragsverarbeiter stellt die zur Darlegung der Einhaltung von Art. 28 DSGVO erforderlichen Informationen bereit. Der Verantwortliche darf nach angemessener Vorankündigung und unter Wahrung der Sicherheit und Vertraulichkeit selbst oder durch einen zur Verschwiegenheit verpflichteten Prüfer kontrollieren. Vorrangig werden aktuelle Dokumentationen und geeignete Nachweise genutzt. Vor-Ort-Prüfungen erfolgen bei berechtigtem Anlass und während üblicher Geschäftszeiten; vermeidbarer Zusatzaufwand kann nach vorheriger Vereinbarung berechnet werden. Rechte der Aufsichtsbehörden bleiben unberührt.
|
||||
|
||||
## 10. Rückgabe und Löschung
|
||||
|
||||
Der Verantwortliche kann die Auftragsdaten vor Vertragsende über die Exportfunktion abrufen. Nach Vertragsende sperrt der Auftragsverarbeiter den Zugang und löscht die operativen Auftragsdaten spätestens nach 30 Tagen, sofern keine gesetzliche Aufbewahrungspflicht entgegensteht. Eine ausdrücklich ausgelöste Mandantenlöschung wirkt sofort. In turnusmäßig überschriebenen Sicherungen können Daten danach noch bis zu 14 Tage enthalten; sie werden nicht erneut produktiv verwendet und bei einer Wiederherstellung erneut dem Löschlauf unterzogen.
|
||||
|
||||
Abrechnungs- und Vertragsdaten, die der Anbieter in eigener Verantwortlichkeit aufbewahren muss, sind nicht Gegenstand der Löschung nach diesem Abschnitt.
|
||||
|
||||
## 11. Verantwortungsbereiche und Schlussbestimmungen
|
||||
|
||||
Der Verantwortliche ist für Zulässigkeit, Transparenz, Datenrichtigkeit, Rechtsgrundlagen, Betroffenenrechte und seine Nutzerberechtigungen verantwortlich. Der Auftragsverarbeiter ist für die vertragsgemäße, sichere Verarbeitung im Auftrag verantwortlich.
|
||||
|
||||
Im Übrigen gelten Hauptvertrag und AGB. Bei Widersprüchen zu Datenschutzpflichten geht dieser AVV vor. Änderungen bedürfen der Textform. Die elektronische Annahme wird mit Version, Zeit, Nutzer und technischen Nachweisdaten protokolliert.
|
||||
|
||||
Stand: 22. August 2026
|
||||
@@ -0,0 +1,89 @@
|
||||
## 1. Verantwortlicher und Kontakt
|
||||
|
||||
Verantwortlich für die eigenen Verarbeitungen des Kaffeeliste-Dienstes ist Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de. Telefonnummer und Ticketsystem stehen im [Impressum](impressum.php). Ein Datenschutzbeauftragter ist derzeit nicht bestellt, weil keine gesetzliche Bestellpflicht festgestellt wurde.
|
||||
|
||||
## 2. Unsere Rollen
|
||||
|
||||
Für Registrierung, Kundenkonto, Vertragsabwicklung, Abrechnung, Support, Sicherheit und den Betrieb der öffentlichen Website ist CTB-IT Verantwortlicher.
|
||||
|
||||
Verwaltet ein Kunde Daten anderer Personen innerhalb seines Mandanten, ist grundsätzlich der Kunde Verantwortlicher und CTB-IT Auftragsverarbeiter. Dies regelt der [AVV](avv.php). Bei ausschließlich persönlichen oder familiären Tätigkeiten eines privaten Kunden kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten. CTB-IT schützt die Daten technisch und vertraglich unabhängig davon.
|
||||
|
||||
## 3. Website, Serverprotokolle und Sicherheit
|
||||
|
||||
Beim Aufruf werden technisch erforderliche Verbindungsdaten verarbeitet: IP-Adresse, Datum und Uhrzeit, angeforderte Adresse, übertragene Datenmenge, Referrer, Browser, Betriebssystem und HTTP-Status. Zweck sind sichere Auslieferung, Fehleranalyse und Abwehr von Angriffen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; das berechtigte Interesse ist ein sicherer und stabiler Dienst.
|
||||
|
||||
Anwendungsseitige IP-Daten aus der Begrenzung automatisierter Versuche werden spätestens nach 24 Stunden gelöscht. Webserver-Protokolle sollen im Produktivbetrieb nach spätestens sieben Tagen gelöscht oder anonymisiert werden, sofern ein Sicherheitsvorfall keine längere, zweckgebundene Sicherung erfordert.
|
||||
|
||||
## 4. Registrierung und Kundenkonto
|
||||
|
||||
Wir verarbeiten Kundenname und -kürzel, Kundentyp, Name, E-Mail-Adresse, Passwort-Hash, E-Mail-Bestätigung, Rollen, Anmeldezeitpunkte, Vertrags- und Rechtstextversionen, Zeitpunkt der Annahme, IP-Adresse und User-Agent. Zwecke sind Vertragsschluss, Authentifizierung, Berechtigungen, Vertragsnachweis und Kommunikation. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b DSGVO sowie für Sicherheits- und Nachweisdaten Art. 6 Abs. 1 lit. f DSGVO. Pflichtfelder sind für Vertragsschluss und sichere Kontoführung erforderlich; ohne sie ist keine Registrierung möglich.
|
||||
|
||||
Ein Klartextpasswort wird nicht gespeichert. Die Kenntnisnahme der Datenschutzerklärung ist keine Einwilligung in Werbung oder Tracking.
|
||||
|
||||
## 5. Mandanten-, Teilnehmer- und Kassendaten
|
||||
|
||||
Je nach Nutzung werden Einstellungen, Marken- und Zahlungsangaben des Kunden, Teilnehmername, E-Mail-Adresse, PayPal-Anzeigename und -E-Mail, Nutzerzuordnung, Rollen, Aktivstatus, Verbrauch, Einzahlungen, Salden, Bemerkungen, Hinweise, FAQ-Inhalte, Importe, Benachrichtigungsstatus und Administrationsprotokolle verarbeitet.
|
||||
|
||||
Bei Auftragsverarbeitung bestimmt der Kunde Zwecke, Rechtsgrundlagen, Inhalte und Empfänger. Die Daten stammen vom Kunden, seinen Administratoren, den Teilnehmern oder aus vom Kunden veranlassten Importen. Teilnehmer sollten sich für Auskunft, Berichtigung oder Löschung zuerst an den jeweiligen Kunden wenden; CTB-IT unterstützt diesen nach dem AVV.
|
||||
|
||||
## 6. Vertrag, Abrechnung, Stripe und Dolibarr
|
||||
|
||||
Bei kostenpflichtigen Tarifen verarbeiten wir Tarif, Teilnehmerzahl, Abrechnungsstatus, Stripe-Kunden- und Abonnementkennung, Laufzeit, Rechnungsbetrag und Zahlungsstatus zur Vertragsdurchführung nach Art. 6 Abs. 1 lit. b DSGVO und für Buchführung und Steuerrecht nach Art. 6 Abs. 1 lit. c DSGVO.
|
||||
|
||||
Zahlungen werden durch Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, Irland, abgewickelt. Stripe erhält insbesondere Kontakt-, Vertrags-, Geräte- und Zahlungsdaten. Stripe verarbeitet einen Teil als unser Auftragsverarbeiter und einen Teil in eigener Verantwortlichkeit, etwa zur Betrugsprävention und Erfüllung finanzrechtlicher Pflichten. Übermittlungen in die USA oder andere Drittländer können auf Angemessenheitsbeschlüssen, insbesondere dem EU-US Data Privacy Framework für zertifizierte Empfänger, oder EU-Standardvertragsklauseln beruhen. Weitere Informationen: [Stripe-Datenschutz](https://stripe.com/de/privacy).
|
||||
|
||||
Bezahlte Rechnungen werden im selbst betriebenen Dolibarr-System unter verwaltung.ctb-it.de für Buchführung und Kundenverwaltung gespiegelt. Dabei werden Kunden-, Vertrags-, Rechnungs- und Zahlungsdaten verarbeitet, aber grundsätzlich keine Teilnehmer- oder Kasseninhalte.
|
||||
|
||||
## 7. PayPal-Zahlungsmails
|
||||
|
||||
Aktiviert ein Kunde die PayPal-Zuordnung, werden an die mandantenspezifische Zahlungsadresse weitergeleitete PayPal-Benachrichtigungen automatisiert ausgelesen. Verarbeitet werden unter anderem Absender- und Empfängeradresse, PayPal-Transaktionskennung, Name und E-Mail des Zahlers, Betrag, Währung, Zahlungszeit und Zuordnungsstatus. Zweck ist die vom Kunden gewünschte Zuordnung zu Teilnehmern und Einzahlungen. Bei Teilnehmerdaten erfolgt dies im Auftrag des Kunden; für die Bereitstellung und Sicherheit der Funktion zusätzlich auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||
|
||||
PayPal selbst verarbeitet Daten in eigener Verantwortlichkeit nach den dortigen Datenschutzinformationen. Die Kaffeeliste ruft keine PayPal-Kontodaten des Kunden über eine PayPal-API ab.
|
||||
|
||||
## 8. E-Mail, Support und Ticketsystem
|
||||
|
||||
Wir senden notwendige E-Mails zur Verifizierung, Anmeldung, Passwortzurücksetzung, Einladung, Vertragsbestätigung, Kündigung, Widerruf und zu vom Kunden aktivierten Kassenbenachrichtigungen. Verarbeitet werden Empfänger, Betreff, Status, Zeitpunkt und gegebenenfalls Fehler. Grundlage sind Art. 6 Abs. 1 lit. b DSGVO beziehungsweise bei Teilnehmernachrichten der Auftrag des Kunden. Versandprotokolle werden regelmäßig nach 180 Tagen gelöscht, soweit sie nicht als Vertragsnachweis länger erforderlich sind.
|
||||
|
||||
Bei Kontakt per E-Mail oder über ticketsystem.ctb-it.de verarbeiten wir die übermittelten Kontaktdaten, Inhalte, Anhänge, Zeitpunkte und technischen Protokolldaten, um die Anfrage zu bearbeiten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bei vorvertraglichen oder vertraglichen Anliegen, sonst Art. 6 Abs. 1 lit. f DSGVO. Unser Interesse ist nachvollziehbarer und sicherer Support. Bitte übermitteln Sie im Ticket keine unnötigen besonderen Kategorien personenbezogener Daten.
|
||||
|
||||
## 9. Hosting, Empfänger und Auftragsverarbeiter
|
||||
|
||||
Die Anwendung, Datenbank, Sitzungen, Sicherungen und der Mailtransport werden auf Infrastruktur der netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland, innerhalb Deutschlands beziehungsweise der Europäischen Union betrieben. Mit netcup besteht ein Vertrag über Auftragsverarbeitung.
|
||||
|
||||
Weitere Empfänger sind Stripe für Zahlung und Abrechnung, Behörden oder Berater bei gesetzlicher Pflicht beziehungsweise Rechtsverteidigung und die vom Kunden bestimmten Nutzer und Empfänger. Eine Weitergabe für fremde Werbung findet nicht statt.
|
||||
|
||||
## 10. Cookies und externe Inhalte
|
||||
|
||||
Die Anwendung setzt ausschließlich das Sitzungs-Cookie „kaffeeliste_session“. Es ist für Login, Mandantenzuordnung, Sicherheit und CSRF-Schutz erforderlich, enthält eine zufällige Sitzungskennung, ist für JavaScript nicht lesbar und wird beim Schließen des Browsers beendet. Grundlage für den Zugriff auf das Endgerät ist § 25 Abs. 2 Nr. 2 TDDDG; die anschließende Verarbeitung beruht auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||
|
||||
Es gibt keine Analyse-, Werbe- oder Social-Media-Cookies. Schriften und Gestaltungselemente werden lokal ausgeliefert; beim bloßen Seitenaufruf wird keine Verbindung zu Google Fonts hergestellt.
|
||||
|
||||
## 11. Speicherdauer und Löschung
|
||||
|
||||
- Kundenkonto und operative Mandantendaten: für die Vertragsdauer; bei einer ausdrücklich ausgelösten Mandantenlöschung sofort aus dem Produktivsystem, sonst 30 Tage nach Vertragsende
|
||||
- technische Rate-Limit-Daten: höchstens 24 Stunden
|
||||
- Admin- und Sicherheitsprotokoll: regelmäßig 180 Tage; bei einem konkreten Vorfall bis zu dessen Abschluss und Ablauf einschlägiger Verjährungsfristen
|
||||
- E-Mail-Versandprotokoll: regelmäßig 180 Tage
|
||||
- Vertragsannahmen, Kündigungen, Widerrufe und erforderliche Korrespondenz: regelmäßig bis zum Ablauf der zivilrechtlichen Verjährung, grundsätzlich drei Jahre ab Ende des Jahres, bei Rechtsstreit länger
|
||||
- Rechnungen, Zahlungs- und Buchungsbelege des Anbieters: acht Jahre oder eine andere gesetzlich vorgeschriebene Dauer nach § 257 HGB und § 147 AO
|
||||
- Sicherungskopien: rollierend bis zu 14 Tage; danach Überschreibung. Bei Wiederherstellung werden zuvor fällige Löschungen erneut ausgeführt.
|
||||
|
||||
Interne Kaffee- oder Getränkebuchungen des Kunden werden nicht pauschal als steuerliche Belege des Anbieters behandelt. Der Kunde bestimmt im Rahmen seiner Verantwortung eigene Aufbewahrungsfristen und kann den Mandanten löschen. Gesetzlich aufzubewahrende Abrechnungsdaten des Anbieters bleiben getrennt erhalten.
|
||||
|
||||
## 12. Ihre Rechte
|
||||
|
||||
Soweit CTB-IT Verantwortlicher ist, bestehen nach den gesetzlichen Voraussetzungen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch. Eine erteilte Einwilligung kann jederzeit für die Zukunft widerrufen werden; derzeit stützt der Dienst keine Werbung oder Analyse auf eine Einwilligung.
|
||||
|
||||
Gegen Verarbeitungen nach Art. 6 Abs. 1 lit. f DSGVO können Sie aus Gründen Ihrer besonderen Situation widersprechen. Bei Direktwerbung wäre ein Widerspruch jederzeit ohne Begründung möglich; Direktwerbung ist derzeit nicht vorgesehen.
|
||||
|
||||
Anfragen richten Sie an info@ctb-it.de oder über das Ticketsystem im Impressum. Sie können sich außerdem bei einer Datenschutzaufsichtsbehörde beschweren. Für den Anbieter ist insbesondere die Landesbeauftragte für den Datenschutz Niedersachsen, Prinzenstraße 5, 30159 Hannover, zuständig.
|
||||
|
||||
## 13. Automatisierte Entscheidungen
|
||||
|
||||
Es findet keine ausschließlich automatisierte Entscheidungsfindung einschließlich Profiling statt, die rechtliche Wirkung entfaltet oder Sie ähnlich erheblich beeinträchtigt. Sicherheitslimits können Anfragen kurzfristig technisch blockieren; eine Vertragsentscheidung wird damit nicht getroffen.
|
||||
|
||||
## 14. Änderungen
|
||||
|
||||
Wir aktualisieren diese Erklärung, wenn sich Verarbeitung oder Rechtslage ändern. Bei wesentlichen Änderungen für bestehende Kunden informieren wir über einen geeigneten Kontaktweg.
|
||||
|
||||
Stand: 22. August 2026
|
||||
@@ -0,0 +1,102 @@
|
||||
## 1. Verantwortlicher und Kontakt
|
||||
|
||||
Verantwortlich für die eigenen Verarbeitungen des Kaffeeliste-Dienstes ist Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de. Telefonnummer und Ticketsystem stehen im [Impressum](impressum.php). Ein Datenschutzbeauftragter ist derzeit nicht bestellt, weil keine gesetzliche Bestellpflicht festgestellt wurde.
|
||||
|
||||
## 2. Unsere Rollen
|
||||
|
||||
Für Registrierung, Kundenkonto, Vertragsabwicklung, Abrechnung, Support, Sicherheit, die optionale Nutzungsanalyse und den Betrieb der öffentlichen Website ist CTB-IT Verantwortlicher.
|
||||
|
||||
Verwaltet ein Kunde Daten anderer Personen innerhalb seines Mandanten, ist grundsätzlich der Kunde Verantwortlicher und CTB-IT Auftragsverarbeiter. Dies regelt der [AVV](avv.php). Bei ausschließlich persönlichen oder familiären Tätigkeiten eines privaten Kunden kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten. CTB-IT schützt die Daten technisch und vertraglich unabhängig davon.
|
||||
|
||||
## 3. Website, Serverprotokolle und anonyme Aufrufzahlen
|
||||
|
||||
Beim Aufruf werden technisch erforderliche Verbindungsdaten verarbeitet: IP-Adresse, Datum und Uhrzeit, angeforderte Adresse, übertragene Datenmenge, Referrer, Browser, Betriebssystem und HTTP-Status. Zweck sind sichere Auslieferung, Fehleranalyse und Abwehr von Angriffen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; das berechtigte Interesse ist ein sicherer und stabiler Dienst.
|
||||
|
||||
Anwendungsseitige IP-Daten aus der Begrenzung automatisierter Versuche werden spätestens nach 24 Stunden gelöscht. Webserver-Protokolle sollen im Produktivbetrieb nach spätestens sieben Tagen gelöscht oder anonymisiert werden, sofern ein Sicherheitsvorfall keine längere, zweckgebundene Sicherung erfordert.
|
||||
|
||||
Für eine einfache Reichweitenzählung erhöhen wir beim GET-Abruf ausgewählter öffentlicher HTML-Seiten einen Tageszähler. Gespeichert werden ausschließlich das Datum, ein fest vorgegebener Seitenschlüssel und die Anzahl der Aufrufe. IP-Adresse, User-Agent, Referrer, vollständige URL, URL-Parameter, Cookie-, Sitzungs- oder Benutzerkennung werden nicht in diese Statistik übernommen. Sie erlaubt daher weder die Ermittlung eindeutiger Besucher noch die Bildung von Nutzungsprofilen. Wiederholte und automatisierte Aufrufe können enthalten sein. Weil keine Information auf dem Endgerät gespeichert oder daraus ausgelesen wird, ist hierfür keine Einwilligung nach § 25 TDDDG erforderlich; die aggregierten Zähldaten weisen keinen Personenbezug auf.
|
||||
|
||||
## 4. Registrierung und Kundenkonto
|
||||
|
||||
Wir verarbeiten Kundenname und -kürzel, Kundentyp, Name, E-Mail-Adresse, Passwort-Hash, E-Mail-Bestätigung, Rollen, Anmeldezeitpunkte, Vertrags- und Rechtstextversionen, Zeitpunkt der Annahme, IP-Adresse und User-Agent. Zwecke sind Vertragsschluss, Authentifizierung, Berechtigungen, Vertragsnachweis und Kommunikation. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b DSGVO sowie für Sicherheits- und Nachweisdaten Art. 6 Abs. 1 lit. f DSGVO. Pflichtfelder sind für Vertragsschluss und sichere Kontoführung erforderlich; ohne sie ist keine Registrierung möglich.
|
||||
|
||||
Ein Klartextpasswort wird nicht gespeichert. Die Kenntnisnahme der Datenschutzerklärung ist keine Einwilligung in Werbung oder Analyse. Eine Einwilligung in die Nutzungsanalyse wird separat über die Cookie-Einstellungen abgefragt.
|
||||
|
||||
## 5. Mandanten-, Teilnehmer- und Kassendaten
|
||||
|
||||
Je nach Nutzung werden Einstellungen, Marken- und Zahlungsangaben des Kunden, Teilnehmername, E-Mail-Adresse, PayPal-Anzeigename und -E-Mail, Nutzerzuordnung, Rollen, Aktivstatus, Verbrauch, Einzahlungen, Salden, Bemerkungen, Hinweise, FAQ-Inhalte, Importe, Benachrichtigungsstatus und Administrationsprotokolle verarbeitet.
|
||||
|
||||
Bei Auftragsverarbeitung bestimmt der Kunde Zwecke, Rechtsgrundlagen, Inhalte und Empfänger. Die Daten stammen vom Kunden, seinen Administratoren, den Teilnehmern oder aus vom Kunden veranlassten Importen. Teilnehmer sollten sich für Auskunft, Berichtigung oder Löschung zuerst an den jeweiligen Kunden wenden; CTB-IT unterstützt diesen nach dem AVV.
|
||||
|
||||
## 6. Vertrag, Abrechnung, Stripe und Dolibarr
|
||||
|
||||
Bei kostenpflichtigen Tarifen verarbeiten wir Tarif, Teilnehmerzahl, Abrechnungsstatus, Stripe-Kunden- und Abonnementkennung, Laufzeit, Rechnungsbetrag und Zahlungsstatus zur Vertragsdurchführung nach Art. 6 Abs. 1 lit. b DSGVO und für Buchführung und Steuerrecht nach Art. 6 Abs. 1 lit. c DSGVO.
|
||||
|
||||
Zahlungen werden durch Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, Irland, abgewickelt. Stripe erhält insbesondere Kontakt-, Vertrags-, Geräte- und Zahlungsdaten. Stripe verarbeitet einen Teil als unser Auftragsverarbeiter und einen Teil in eigener Verantwortlichkeit, etwa zur Betrugsprävention und Erfüllung finanzrechtlicher Pflichten. Übermittlungen in die USA oder andere Drittländer können auf Angemessenheitsbeschlüssen, insbesondere dem EU-US Data Privacy Framework für zertifizierte Empfänger, oder EU-Standardvertragsklauseln beruhen. Weitere Informationen: [Stripe-Datenschutz](https://stripe.com/de/privacy).
|
||||
|
||||
Bezahlte Rechnungen werden im selbst betriebenen Dolibarr-System unter verwaltung.ctb-it.de für Buchführung und Kundenverwaltung gespiegelt. Dabei werden Kunden-, Vertrags-, Rechnungs- und Zahlungsdaten verarbeitet, aber grundsätzlich keine Teilnehmer- oder Kasseninhalte.
|
||||
|
||||
## 7. PayPal-Zahlungsmails
|
||||
|
||||
Aktiviert ein Kunde die PayPal-Zuordnung, werden an die mandantenspezifische Zahlungsadresse weitergeleitete PayPal-Benachrichtigungen automatisiert ausgelesen. Verarbeitet werden unter anderem Absender- und Empfängeradresse, PayPal-Transaktionskennung, Name und E-Mail des Zahlers, Betrag, Währung, Zahlungszeit und Zuordnungsstatus. Zweck ist die vom Kunden gewünschte Zuordnung zu Teilnehmern und Einzahlungen. Bei Teilnehmerdaten erfolgt dies im Auftrag des Kunden; für die Bereitstellung und Sicherheit der Funktion zusätzlich auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||
|
||||
PayPal selbst verarbeitet Daten in eigener Verantwortlichkeit nach den dortigen Datenschutzinformationen. Die Kaffeeliste ruft keine PayPal-Kontodaten des Kunden über eine PayPal-API ab.
|
||||
|
||||
## 8. E-Mail, Support und Ticketsystem
|
||||
|
||||
Wir senden notwendige E-Mails zur Verifizierung, Anmeldung, Passwortzurücksetzung, Einladung, Vertragsbestätigung, Kündigung, Widerruf und zu vom Kunden aktivierten Kassenbenachrichtigungen. Verarbeitet werden Empfänger, Betreff, Status, Zeitpunkt und gegebenenfalls Fehler. Grundlage sind Art. 6 Abs. 1 lit. b DSGVO beziehungsweise bei Teilnehmernachrichten der Auftrag des Kunden. Versandprotokolle werden regelmäßig nach 180 Tagen gelöscht, soweit sie nicht als Vertragsnachweis länger erforderlich sind.
|
||||
|
||||
Bei Kontakt per E-Mail oder über ticketsystem.ctb-it.de verarbeiten wir die übermittelten Kontaktdaten, Inhalte, Anhänge, Zeitpunkte und technischen Protokolldaten, um die Anfrage zu bearbeiten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bei vorvertraglichen oder vertraglichen Anliegen, sonst Art. 6 Abs. 1 lit. f DSGVO. Unser Interesse ist nachvollziehbarer und sicherer Support. Bitte übermitteln Sie im Ticket keine unnötigen besonderen Kategorien personenbezogener Daten.
|
||||
|
||||
## 9. Hosting, Empfänger und Auftragsverarbeiter
|
||||
|
||||
Die Anwendung, Datenbank, Sitzungen, Sicherungen, der Mailtransport und die selbst betriebene Matomo-Instanz werden auf Infrastruktur der netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland, innerhalb Deutschlands beziehungsweise der Europäischen Union betrieben. Mit netcup besteht ein Vertrag über Auftragsverarbeitung. Durch die Nutzungsanalyse werden keine Daten an Matomo Cloud übermittelt.
|
||||
|
||||
Weitere Empfänger sind Stripe für Zahlung und Abrechnung, Behörden oder Berater bei gesetzlicher Pflicht beziehungsweise Rechtsverteidigung und die vom Kunden bestimmten Nutzer und Empfänger. Eine Weitergabe für fremde Werbung findet nicht statt.
|
||||
|
||||
## 10. Cookies, Einwilligung und Matomo
|
||||
|
||||
Die Anwendung setzt das Sitzungs-Cookie „kaffeeliste_session“. Es ist für Login, Mandantenzuordnung, Sicherheit und CSRF-Schutz erforderlich, enthält eine zufällige Sitzungskennung, ist für JavaScript nicht lesbar und wird beim Schließen des Browsers beendet. Grundlage für den Zugriff auf das Endgerät ist § 25 Abs. 2 Nr. 2 TDDDG; die anschließende Verarbeitung beruht auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||
|
||||
Das Cookie „kaffeeliste_analytics_consent“ speichert für 180 Tage ausschließlich die Version der Abfrage und die Entscheidung „erlaubt“ oder „abgelehnt“. Es enthält keine Benutzerkennung. Es ist erforderlich, um die gewünschte Einstellung umzusetzen und nicht bei jedem Seitenaufruf erneut zu fragen. Grundlage sind § 25 Abs. 2 Nr. 2 TDDDG und, soweit die Einstellung personenbezogen ist, Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist eine nutzerfreundliche und zuverlässige Einwilligungsverwaltung.
|
||||
|
||||
Nur nach einer freiwilligen Einwilligung verwenden wir die selbst betriebene Analysesoftware Matomo, um Aufrufe und Navigationswege auf der öffentlichen Website und in der App auszuwerten. Der Browser sendet die Analysedaten dabei zunächst an die gerade besuchte Kaffeeliste-Domain; unser Server leitet sie an die fest konfigurierte Matomo-Instanz weiter. Die IP-Adresse des Endgeräts wird nicht an Matomo weitergegeben. Verarbeitet werden können Zeitpunkt, aufgerufener Seitenpfad und Seitentitel, zuvor besuchter Kaffeeliste-Seitenpfad, Browsertyp, Betriebssystem, Sprache, Bildschirmauflösung sowie eine zufällige Besucherkennung. URL-Abfragen und Fragmente werden vor der Übermittlung entfernt; dadurch werden insbesondere Verifizierungs-, Passwort-Reset- und Zugriffs-Tokens nicht an Matomo gesendet. Formulareingaben, Kasseninhalte und eine Kaffeeliste-Benutzerkennung werden nicht an Matomo übermittelt. Browser-Funktionserkennung, Performance-Messung, Link-Klick-Tracking, Heatmaps und Sitzungsaufzeichnungen sind nicht aktiviert.
|
||||
|
||||
Nach der Einwilligung setzen wir das Cookie „kaffeeliste_analytics_visitor“ mit einer zufälligen, nicht aus anderen Angaben abgeleiteten Besucherkennung für bis zu 180 Tage. Es ermöglicht Matomo, mehrere Seitenaufrufe desselben Browsers als Navigationsweg zusammenzufassen. Eine konto- oder personenbezogene Nutzer-ID wird nicht verwendet. Rechtsgrundlagen sind § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO. Die Einwilligung ist freiwillig; ihre Ablehnung hat keinen Einfluss auf die Nutzung des Dienstes.
|
||||
|
||||
Die IP-Adresse wird in Matomo vor ihrer dauerhaften Speicherung um mindestens zwei Bytes gekürzt. Roh- und Besuchsdaten werden spätestens nach 180 Tagen gelöscht; ausschließlich verdichtete Berichte werden höchstens 25 Monate aufbewahrt. Es findet kein websiteübergreifendes Tracking, keine Werbung und keine Weitergabe der Analysedaten an Dritte zu deren eigenen Zwecken statt.
|
||||
|
||||
Die Entscheidung kann jederzeit über die ständig erreichbare Schaltfläche „Cookie-Einstellungen“ geändert werden. Bei einem Widerruf wird die künftige Analyse beendet und das Analyse-Besuchercookie sowie gegebenenfalls noch vorhandene ältere Matomo-Cookies werden gelöscht. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Verarbeitung bleibt unberührt.
|
||||
|
||||
Schriften und Gestaltungselemente werden lokal ausgeliefert; beim bloßen Seitenaufruf wird keine Verbindung zu Google Fonts hergestellt.
|
||||
|
||||
## 11. Speicherdauer und Löschung
|
||||
|
||||
- Kundenkonto und operative Mandantendaten: für die Vertragsdauer; bei einer ausdrücklich ausgelösten Mandantenlöschung sofort aus dem Produktivsystem, sonst 30 Tage nach Vertragsende
|
||||
- technische Rate-Limit-Daten: höchstens 24 Stunden
|
||||
- Admin- und Sicherheitsprotokoll: regelmäßig 180 Tage; bei einem konkreten Vorfall bis zu dessen Abschluss und Ablauf einschlägiger Verjährungsfristen
|
||||
- E-Mail-Versandprotokoll: regelmäßig 180 Tage
|
||||
- Matomo-Roh- und Besuchsdaten: höchstens 180 Tage; verdichtete Analyseberichte höchstens 25 Monate
|
||||
- Vertragsannahmen, Kündigungen, Widerrufe und erforderliche Korrespondenz: regelmäßig bis zum Ablauf der zivilrechtlichen Verjährung, grundsätzlich drei Jahre ab Ende des Jahres, bei Rechtsstreit länger
|
||||
- Rechnungen, Zahlungs- und Buchungsbelege des Anbieters: acht Jahre oder eine andere gesetzlich vorgeschriebene Dauer nach § 257 HGB und § 147 AO
|
||||
- Sicherungskopien: rollierend bis zu 14 Tage; danach Überschreibung. Bei Wiederherstellung werden zuvor fällige Löschungen erneut ausgeführt.
|
||||
|
||||
Interne Kaffee- oder Getränkebuchungen des Kunden werden nicht pauschal als steuerliche Belege des Anbieters behandelt. Der Kunde bestimmt im Rahmen seiner Verantwortung eigene Aufbewahrungsfristen und kann den Mandanten löschen. Gesetzlich aufzubewahrende Abrechnungsdaten des Anbieters bleiben getrennt erhalten.
|
||||
|
||||
## 12. Ihre Rechte
|
||||
|
||||
Soweit CTB-IT Verantwortlicher ist, bestehen nach den gesetzlichen Voraussetzungen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch. Eine erteilte Einwilligung in die Nutzungsanalyse kann jederzeit über „Cookie-Einstellungen“ mit Wirkung für die Zukunft widerrufen werden.
|
||||
|
||||
Gegen Verarbeitungen nach Art. 6 Abs. 1 lit. f DSGVO können Sie aus Gründen Ihrer besonderen Situation widersprechen. Bei Direktwerbung wäre ein Widerspruch jederzeit ohne Begründung möglich; Direktwerbung ist derzeit nicht vorgesehen.
|
||||
|
||||
Anfragen richten Sie an info@ctb-it.de oder über das Ticketsystem im Impressum. Sie können sich außerdem bei einer Datenschutzaufsichtsbehörde beschweren. Für den Anbieter ist insbesondere die Landesbeauftragte für den Datenschutz Niedersachsen, Prinzenstraße 5, 30159 Hannover, zuständig.
|
||||
|
||||
## 13. Automatisierte Entscheidungen
|
||||
|
||||
Es findet keine ausschließlich automatisierte Entscheidungsfindung einschließlich Profiling statt, die rechtliche Wirkung entfaltet oder Sie ähnlich erheblich beeinträchtigt. Sicherheitslimits können Anfragen kurzfristig technisch blockieren; eine Vertragsentscheidung wird damit nicht getroffen.
|
||||
|
||||
## 14. Änderungen
|
||||
|
||||
Wir aktualisieren diese Erklärung, wenn sich Verarbeitung oder Rechtslage ändern. Bei wesentlichen Änderungen für bestehende Kunden informieren wir über einen geeigneten Kontaktweg.
|
||||
|
||||
Stand: 27. August 2026
|
||||
@@ -0,0 +1,100 @@
|
||||
## 1. Verantwortlicher und Kontakt
|
||||
|
||||
Verantwortlich für die eigenen Verarbeitungen des Kaffeeliste-Dienstes ist Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de. Telefonnummer und Ticketsystem stehen im [Impressum](impressum.php). Ein Datenschutzbeauftragter ist derzeit nicht bestellt, weil keine gesetzliche Bestellpflicht festgestellt wurde.
|
||||
|
||||
## 2. Unsere Rollen
|
||||
|
||||
Für Registrierung, Kundenkonto, Vertragsabwicklung, Abrechnung, Support, Sicherheit, die optionale Nutzungsanalyse und den Betrieb der öffentlichen Website ist CTB-IT Verantwortlicher.
|
||||
|
||||
Verwaltet ein Kunde Daten anderer Personen innerhalb seines Mandanten, ist grundsätzlich der Kunde Verantwortlicher und CTB-IT Auftragsverarbeiter. Dies regelt der [AVV](avv.php). Bei ausschließlich persönlichen oder familiären Tätigkeiten eines privaten Kunden kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten. CTB-IT schützt die Daten technisch und vertraglich unabhängig davon.
|
||||
|
||||
## 3. Website, Serverprotokolle und Sicherheit
|
||||
|
||||
Beim Aufruf werden technisch erforderliche Verbindungsdaten verarbeitet: IP-Adresse, Datum und Uhrzeit, angeforderte Adresse, übertragene Datenmenge, Referrer, Browser, Betriebssystem und HTTP-Status. Zweck sind sichere Auslieferung, Fehleranalyse und Abwehr von Angriffen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; das berechtigte Interesse ist ein sicherer und stabiler Dienst.
|
||||
|
||||
Anwendungsseitige IP-Daten aus der Begrenzung automatisierter Versuche werden spätestens nach 24 Stunden gelöscht. Webserver-Protokolle sollen im Produktivbetrieb nach spätestens sieben Tagen gelöscht oder anonymisiert werden, sofern ein Sicherheitsvorfall keine längere, zweckgebundene Sicherung erfordert.
|
||||
|
||||
## 4. Registrierung und Kundenkonto
|
||||
|
||||
Wir verarbeiten Kundenname und -kürzel, Kundentyp, Name, E-Mail-Adresse, Passwort-Hash, E-Mail-Bestätigung, Rollen, Anmeldezeitpunkte, Vertrags- und Rechtstextversionen, Zeitpunkt der Annahme, IP-Adresse und User-Agent. Zwecke sind Vertragsschluss, Authentifizierung, Berechtigungen, Vertragsnachweis und Kommunikation. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b DSGVO sowie für Sicherheits- und Nachweisdaten Art. 6 Abs. 1 lit. f DSGVO. Pflichtfelder sind für Vertragsschluss und sichere Kontoführung erforderlich; ohne sie ist keine Registrierung möglich.
|
||||
|
||||
Ein Klartextpasswort wird nicht gespeichert. Die Kenntnisnahme der Datenschutzerklärung ist keine Einwilligung in Werbung oder Analyse. Eine Einwilligung in die Nutzungsanalyse wird separat über die Cookie-Einstellungen abgefragt.
|
||||
|
||||
## 5. Mandanten-, Teilnehmer- und Kassendaten
|
||||
|
||||
Je nach Nutzung werden Einstellungen, Marken- und Zahlungsangaben des Kunden, Teilnehmername, E-Mail-Adresse, PayPal-Anzeigename und -E-Mail, Nutzerzuordnung, Rollen, Aktivstatus, Verbrauch, Einzahlungen, Salden, Bemerkungen, Hinweise, FAQ-Inhalte, Importe, Benachrichtigungsstatus und Administrationsprotokolle verarbeitet.
|
||||
|
||||
Bei Auftragsverarbeitung bestimmt der Kunde Zwecke, Rechtsgrundlagen, Inhalte und Empfänger. Die Daten stammen vom Kunden, seinen Administratoren, den Teilnehmern oder aus vom Kunden veranlassten Importen. Teilnehmer sollten sich für Auskunft, Berichtigung oder Löschung zuerst an den jeweiligen Kunden wenden; CTB-IT unterstützt diesen nach dem AVV.
|
||||
|
||||
## 6. Vertrag, Abrechnung, Stripe und Dolibarr
|
||||
|
||||
Bei kostenpflichtigen Tarifen verarbeiten wir Tarif, Teilnehmerzahl, Abrechnungsstatus, Stripe-Kunden- und Abonnementkennung, Laufzeit, Rechnungsbetrag und Zahlungsstatus zur Vertragsdurchführung nach Art. 6 Abs. 1 lit. b DSGVO und für Buchführung und Steuerrecht nach Art. 6 Abs. 1 lit. c DSGVO.
|
||||
|
||||
Zahlungen werden durch Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, Irland, abgewickelt. Stripe erhält insbesondere Kontakt-, Vertrags-, Geräte- und Zahlungsdaten. Stripe verarbeitet einen Teil als unser Auftragsverarbeiter und einen Teil in eigener Verantwortlichkeit, etwa zur Betrugsprävention und Erfüllung finanzrechtlicher Pflichten. Übermittlungen in die USA oder andere Drittländer können auf Angemessenheitsbeschlüssen, insbesondere dem EU-US Data Privacy Framework für zertifizierte Empfänger, oder EU-Standardvertragsklauseln beruhen. Weitere Informationen: [Stripe-Datenschutz](https://stripe.com/de/privacy).
|
||||
|
||||
Bezahlte Rechnungen werden im selbst betriebenen Dolibarr-System unter verwaltung.ctb-it.de für Buchführung und Kundenverwaltung gespiegelt. Dabei werden Kunden-, Vertrags-, Rechnungs- und Zahlungsdaten verarbeitet, aber grundsätzlich keine Teilnehmer- oder Kasseninhalte.
|
||||
|
||||
## 7. PayPal-Zahlungsmails
|
||||
|
||||
Aktiviert ein Kunde die PayPal-Zuordnung, werden an die mandantenspezifische Zahlungsadresse weitergeleitete PayPal-Benachrichtigungen automatisiert ausgelesen. Verarbeitet werden unter anderem Absender- und Empfängeradresse, PayPal-Transaktionskennung, Name und E-Mail des Zahlers, Betrag, Währung, Zahlungszeit und Zuordnungsstatus. Zweck ist die vom Kunden gewünschte Zuordnung zu Teilnehmern und Einzahlungen. Bei Teilnehmerdaten erfolgt dies im Auftrag des Kunden; für die Bereitstellung und Sicherheit der Funktion zusätzlich auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||
|
||||
PayPal selbst verarbeitet Daten in eigener Verantwortlichkeit nach den dortigen Datenschutzinformationen. Die Kaffeeliste ruft keine PayPal-Kontodaten des Kunden über eine PayPal-API ab.
|
||||
|
||||
## 8. E-Mail, Support und Ticketsystem
|
||||
|
||||
Wir senden notwendige E-Mails zur Verifizierung, Anmeldung, Passwortzurücksetzung, Einladung, Vertragsbestätigung, Kündigung, Widerruf und zu vom Kunden aktivierten Kassenbenachrichtigungen. Verarbeitet werden Empfänger, Betreff, Status, Zeitpunkt und gegebenenfalls Fehler. Grundlage sind Art. 6 Abs. 1 lit. b DSGVO beziehungsweise bei Teilnehmernachrichten der Auftrag des Kunden. Versandprotokolle werden regelmäßig nach 180 Tagen gelöscht, soweit sie nicht als Vertragsnachweis länger erforderlich sind.
|
||||
|
||||
Bei Kontakt per E-Mail oder über ticketsystem.ctb-it.de verarbeiten wir die übermittelten Kontaktdaten, Inhalte, Anhänge, Zeitpunkte und technischen Protokolldaten, um die Anfrage zu bearbeiten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bei vorvertraglichen oder vertraglichen Anliegen, sonst Art. 6 Abs. 1 lit. f DSGVO. Unser Interesse ist nachvollziehbarer und sicherer Support. Bitte übermitteln Sie im Ticket keine unnötigen besonderen Kategorien personenbezogener Daten.
|
||||
|
||||
## 9. Hosting, Empfänger und Auftragsverarbeiter
|
||||
|
||||
Die Anwendung, Datenbank, Sitzungen, Sicherungen, der Mailtransport und die selbst betriebene Matomo-Instanz werden auf Infrastruktur der netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland, innerhalb Deutschlands beziehungsweise der Europäischen Union betrieben. Mit netcup besteht ein Vertrag über Auftragsverarbeitung. Durch die Nutzungsanalyse werden keine Daten an Matomo Cloud übermittelt.
|
||||
|
||||
Weitere Empfänger sind Stripe für Zahlung und Abrechnung, Behörden oder Berater bei gesetzlicher Pflicht beziehungsweise Rechtsverteidigung und die vom Kunden bestimmten Nutzer und Empfänger. Eine Weitergabe für fremde Werbung findet nicht statt.
|
||||
|
||||
## 10. Cookies, Einwilligung und Matomo
|
||||
|
||||
Die Anwendung setzt das Sitzungs-Cookie „kaffeeliste_session“. Es ist für Login, Mandantenzuordnung, Sicherheit und CSRF-Schutz erforderlich, enthält eine zufällige Sitzungskennung, ist für JavaScript nicht lesbar und wird beim Schließen des Browsers beendet. Grundlage für den Zugriff auf das Endgerät ist § 25 Abs. 2 Nr. 2 TDDDG; die anschließende Verarbeitung beruht auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||
|
||||
Das Cookie „kaffeeliste_analytics_consent“ speichert für 180 Tage ausschließlich die Version der Abfrage und die Entscheidung „erlaubt“ oder „abgelehnt“. Es enthält keine Benutzerkennung. Es ist erforderlich, um die gewünschte Einstellung umzusetzen und nicht bei jedem Seitenaufruf erneut zu fragen. Grundlage sind § 25 Abs. 2 Nr. 2 TDDDG und, soweit die Einstellung personenbezogen ist, Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist eine nutzerfreundliche und zuverlässige Einwilligungsverwaltung.
|
||||
|
||||
Nur nach einer freiwilligen Einwilligung verwenden wir die selbst betriebene Analysesoftware Matomo, um Aufrufe und Navigationswege auf der öffentlichen Website und in der App auszuwerten. Dabei können gekürzte IP-Adresse, Zeitpunkt, aufgerufener Seitenpfad und Seitentitel, zuvor besuchter Seitenpfad, Browser- und Geräteinformationen sowie eine zufällige Besucherkennung verarbeitet werden. URL-Abfragen und Fragmente werden vor der Übermittlung entfernt; dadurch werden insbesondere Verifizierungs-, Passwort-Reset- und Zugriffs-Tokens nicht an Matomo gesendet. Formulareingaben, Kasseninhalte und eine Kaffeeliste-Benutzerkennung werden nicht an Matomo übermittelt. Browser-Einstellungen „Do Not Track“ werden beachtet. Browser-Funktionserkennung, Performance-Messung, Link-Klick-Tracking, Heatmaps und Sitzungsaufzeichnungen sind nicht aktiviert.
|
||||
|
||||
Nach der Einwilligung kann Matomo insbesondere die Cookies „_pk_id.*“ zur Wiedererkennung eines Browsers für bis zu 180 Tage, „_pk_ses.*“ für eine Sitzung von regelmäßig 30 Minuten und gegebenenfalls „_pk_ref.*“ für Referrer-Informationen für bis zu 180 Tage setzen. Rechtsgrundlagen sind § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO. Die Einwilligung ist freiwillig; ihre Ablehnung hat keinen Einfluss auf die Nutzung des Dienstes.
|
||||
|
||||
Die IP-Adresse wird in Matomo vor ihrer dauerhaften Speicherung um mindestens zwei Bytes gekürzt. Roh- und Besuchsdaten werden spätestens nach 180 Tagen gelöscht; ausschließlich verdichtete Berichte werden höchstens 25 Monate aufbewahrt. Es findet kein websiteübergreifendes Tracking, keine Werbung und keine Weitergabe der Analysedaten an Dritte zu deren eigenen Zwecken statt.
|
||||
|
||||
Die Entscheidung kann jederzeit über die ständig erreichbare Schaltfläche „Cookie-Einstellungen“ geändert werden. Bei einem Widerruf wird die künftige Analyse beendet und erreichbare Matomo-Cookies werden gelöscht. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Verarbeitung bleibt unberührt.
|
||||
|
||||
Schriften und Gestaltungselemente werden lokal ausgeliefert; beim bloßen Seitenaufruf wird keine Verbindung zu Google Fonts hergestellt.
|
||||
|
||||
## 11. Speicherdauer und Löschung
|
||||
|
||||
- Kundenkonto und operative Mandantendaten: für die Vertragsdauer; bei einer ausdrücklich ausgelösten Mandantenlöschung sofort aus dem Produktivsystem, sonst 30 Tage nach Vertragsende
|
||||
- technische Rate-Limit-Daten: höchstens 24 Stunden
|
||||
- Admin- und Sicherheitsprotokoll: regelmäßig 180 Tage; bei einem konkreten Vorfall bis zu dessen Abschluss und Ablauf einschlägiger Verjährungsfristen
|
||||
- E-Mail-Versandprotokoll: regelmäßig 180 Tage
|
||||
- Matomo-Roh- und Besuchsdaten: höchstens 180 Tage; verdichtete Analyseberichte höchstens 25 Monate
|
||||
- Vertragsannahmen, Kündigungen, Widerrufe und erforderliche Korrespondenz: regelmäßig bis zum Ablauf der zivilrechtlichen Verjährung, grundsätzlich drei Jahre ab Ende des Jahres, bei Rechtsstreit länger
|
||||
- Rechnungen, Zahlungs- und Buchungsbelege des Anbieters: acht Jahre oder eine andere gesetzlich vorgeschriebene Dauer nach § 257 HGB und § 147 AO
|
||||
- Sicherungskopien: rollierend bis zu 14 Tage; danach Überschreibung. Bei Wiederherstellung werden zuvor fällige Löschungen erneut ausgeführt.
|
||||
|
||||
Interne Kaffee- oder Getränkebuchungen des Kunden werden nicht pauschal als steuerliche Belege des Anbieters behandelt. Der Kunde bestimmt im Rahmen seiner Verantwortung eigene Aufbewahrungsfristen und kann den Mandanten löschen. Gesetzlich aufzubewahrende Abrechnungsdaten des Anbieters bleiben getrennt erhalten.
|
||||
|
||||
## 12. Ihre Rechte
|
||||
|
||||
Soweit CTB-IT Verantwortlicher ist, bestehen nach den gesetzlichen Voraussetzungen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch. Eine erteilte Einwilligung in die Nutzungsanalyse kann jederzeit über „Cookie-Einstellungen“ mit Wirkung für die Zukunft widerrufen werden.
|
||||
|
||||
Gegen Verarbeitungen nach Art. 6 Abs. 1 lit. f DSGVO können Sie aus Gründen Ihrer besonderen Situation widersprechen. Bei Direktwerbung wäre ein Widerspruch jederzeit ohne Begründung möglich; Direktwerbung ist derzeit nicht vorgesehen.
|
||||
|
||||
Anfragen richten Sie an info@ctb-it.de oder über das Ticketsystem im Impressum. Sie können sich außerdem bei einer Datenschutzaufsichtsbehörde beschweren. Für den Anbieter ist insbesondere die Landesbeauftragte für den Datenschutz Niedersachsen, Prinzenstraße 5, 30159 Hannover, zuständig.
|
||||
|
||||
## 13. Automatisierte Entscheidungen
|
||||
|
||||
Es findet keine ausschließlich automatisierte Entscheidungsfindung einschließlich Profiling statt, die rechtliche Wirkung entfaltet oder Sie ähnlich erheblich beeinträchtigt. Sicherheitslimits können Anfragen kurzfristig technisch blockieren; eine Vertragsentscheidung wird damit nicht getroffen.
|
||||
|
||||
## 14. Änderungen
|
||||
|
||||
Wir aktualisieren diese Erklärung, wenn sich Verarbeitung oder Rechtslage ändern. Bei wesentlichen Änderungen für bestehende Kunden informieren wir über einen geeigneten Kontaktweg.
|
||||
|
||||
Stand: 27. August 2026
|
||||
@@ -0,0 +1,32 @@
|
||||
## Widerrufsbelehrung für Verbraucher
|
||||
|
||||
### Widerrufsrecht
|
||||
|
||||
Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
|
||||
|
||||
Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.
|
||||
|
||||
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de; Telefonnummer siehe Impressum) mittels einer eindeutigen Erklärung, zum Beispiel per Brief oder E-Mail, über Ihren Entschluss informieren, diesen Vertrag zu widerrufen. Sie können außerdem die hervorgehoben bereitgestellte elektronische Funktion [Vertrag widerrufen](widerrufen.php) verwenden. Sie können das nachstehende Muster-Widerrufsformular nutzen; dies ist nicht vorgeschrieben.
|
||||
|
||||
Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
|
||||
|
||||
### Folgen des Widerrufs
|
||||
|
||||
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf bei uns eingegangen ist. Für die Rückzahlung verwenden wir dasselbe Zahlungsmittel wie bei der ursprünglichen Transaktion, sofern nicht ausdrücklich etwas anderes vereinbart wurde; Ihnen werden wegen dieser Rückzahlung keine Entgelte berechnet.
|
||||
|
||||
Haben Sie verlangt, dass eine kostenpflichtige Dienstleistung während der Widerrufsfrist beginnen soll, zahlen Sie uns einen angemessenen Betrag. Dieser entspricht dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns über den Widerruf unterrichten, bereits erbrachten Leistung im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Leistung.
|
||||
|
||||
## Muster-Widerrufsformular
|
||||
|
||||
Wenn Sie den Vertrag widerrufen wollen, können Sie folgenden Text an Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de senden:
|
||||
|
||||
Hiermit widerrufe ich den von mir abgeschlossenen Vertrag über die Erbringung der Kaffeeliste-Dienstleistung.
|
||||
|
||||
- Bestellt am:
|
||||
- Name des Verbrauchers:
|
||||
- Anschrift des Verbrauchers:
|
||||
- Kundenkürzel oder Vertragsreferenz:
|
||||
- Datum:
|
||||
- Unterschrift (nur bei Mitteilung auf Papier):
|
||||
|
||||
Stand: 22. August 2026
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/rate-limit.php';
|
||||
require_once __DIR__ . '/app/totp.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
|
||||
app_record_public_page_view('login');
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$errors = [];
|
||||
$email = trim((string)($_POST['email'] ?? ''));
|
||||
$tenantSlug = trim((string)($_POST['tenant_slug'] ?? ''));
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
@@ -17,13 +20,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!$emailBucketOk || !$ipBucketOk) {
|
||||
$errors = ['Zu viele Anmeldeversuche. Bitte versuche es in einigen Minuten erneut.'];
|
||||
} else {
|
||||
// Bewusst ohne Mandanten-Einschraenkung: gehoert die E-Mail zu
|
||||
// mehreren Mandanten, uebernimmt mandant-auswahl.php die Auswahl per
|
||||
// Klarnamen. Ein Kundenkuerzel muss dafuer niemand kennen.
|
||||
$result = saas_authenticate(
|
||||
$pdo,
|
||||
$email,
|
||||
(string)($_POST['password'] ?? ''),
|
||||
$tenantSlug
|
||||
(string)($_POST['password'] ?? '')
|
||||
);
|
||||
|
||||
// Stimmt das Passwort und ist ein zweiter Faktor scharf, wird die
|
||||
// Anmeldung hier nicht abgeschlossen: es entsteht nur ein
|
||||
// Zwischenzustand ohne jeden Zugriff. Das gilt fuer beide Faelle -
|
||||
// auch wer noch einen Mandanten auswaehlen muesste, kommt erst nach
|
||||
// dem Code dorthin.
|
||||
if ($result['ok']) {
|
||||
$totpUserId = (int)($result['user_id'] ?? $result['identity']['user_id'] ?? 0);
|
||||
if ($totpUserId > 0 && app_totp_is_active($pdo, $totpUserId)) {
|
||||
app_totp_start_challenge($totpUserId);
|
||||
header('Location: zwei-faktor.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($result['ok'] && !empty($result['needs_tenant_selection'])) {
|
||||
saas_start_pending_tenant_selection((int)$result['user_id']);
|
||||
header('Location: mandant-auswahl.php');
|
||||
@@ -53,6 +72,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
@@ -96,10 +116,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" name="password" id="password" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="tenant_slug">Kundenkürzel</label>
|
||||
<input type="text" name="tenant_slug" id="tenant_slug" value="<?php echo saas_html($tenantSlug); ?>" placeholder="optional">
|
||||
</div>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit" class="primary">Einloggen</button></li>
|
||||
@@ -115,9 +131,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
<a href="avv.php">AVV</a>
|
||||
<a href="widerruf.php">Widerrufsbelehrung</a>
|
||||
<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>
|
||||
<a href="preise.php">Preise</a>
|
||||
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||
<?php echo app_analytics_footer_settings_button_html(); ?>
|
||||
</footer>
|
||||
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
+12
-1
@@ -4,6 +4,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/saas-mail.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -16,6 +17,14 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktion: $hasAccess faellt zurueck, damit auch die
|
||||
// POST-Verarbeitung weiter unten nicht mehr laeuft.
|
||||
$gesperrteFunktion = null;
|
||||
if ($hasAccess && !app_feature_enabled($pdo, $tenantId, 'mail_dispatch')) {
|
||||
$gesperrteFunktion = 'mail_dispatch';
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
$ergebnisse = null;
|
||||
$dryRun = true;
|
||||
$liveFlash = false;
|
||||
@@ -117,7 +126,9 @@ include "nav.php";
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<?php if ($gesperrteFunktion !== null): ?>
|
||||
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
|
||||
<?php elseif (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
|
||||
+102
-6
@@ -4,10 +4,12 @@ require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/tenant-logo.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
$canManageSettings = saas_can_manage_tenant_settings($user);
|
||||
$brandingEnabled = app_feature_enabled($pdo, (int)$user['tenant_id'], 'branding');
|
||||
$errors = [];
|
||||
$saved = false;
|
||||
$settings = null;
|
||||
@@ -15,12 +17,39 @@ $settings = null;
|
||||
if ($canManageSettings) {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
$result = saas_update_tenant_settings($pdo, (int)$user['tenant_id'], $_POST);
|
||||
$eingaben = $_POST;
|
||||
// Ohne freigeschaltetes Design fehlt das Farbfeld im Formular. Der
|
||||
// gespeicherte Wert wird dann durchgereicht statt geleert, damit eine
|
||||
// spaetere Freischaltung die alte Farbe wiederfindet.
|
||||
if (!$brandingEnabled) {
|
||||
$bestehend = saas_fetch_tenant_settings($pdo, (int)$user['tenant_id']);
|
||||
$eingaben['brand_color'] = (string)($bestehend['brand_color'] ?? '');
|
||||
}
|
||||
$result = saas_update_tenant_settings($pdo, (int)$user['tenant_id'], $eingaben);
|
||||
if ($result['ok']) {
|
||||
$saved = true;
|
||||
$settings = $result['settings'];
|
||||
app_audit_log($pdo, (int)$user['tenant_id'], (int)$user['user_id'], 'tenant_settings.updated', 'tenant', (int)$user['tenant_id']);
|
||||
$tenantIdLogo = (int)$user['tenant_id'];
|
||||
|
||||
// Marken-Logo der App-Oberflaeche: nur wenn der Betreiber das
|
||||
// eigene Design fuer diesen Mandanten freigeschaltet hat.
|
||||
if ($brandingEnabled) {
|
||||
if (!empty($_POST['brand_logo_remove'])) {
|
||||
tenant_logo_delete((string)($settings['brand_logo'] ?? ''));
|
||||
$pdo->prepare("UPDATE tenant_settings SET brand_logo = '' WHERE tenant_id = ?")->execute([$tenantIdLogo]);
|
||||
$settings['brand_logo'] = '';
|
||||
} elseif (isset($_FILES['brand_logo_file']) && ($_FILES['brand_logo_file']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
|
||||
$brandResult = tenant_logo_store($tenantIdLogo, $_FILES['brand_logo_file'], 'brand');
|
||||
if ($brandResult['ok']) {
|
||||
$pdo->prepare('UPDATE tenant_settings SET brand_logo = ? WHERE tenant_id = ?')->execute([$brandResult['filename'], $tenantIdLogo]);
|
||||
$settings['brand_logo'] = $brandResult['filename'];
|
||||
} else {
|
||||
$errors[] = $brandResult['error'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($_POST['pdf_watermark_logo_remove'])) {
|
||||
tenant_logo_delete((string)($settings['pdf_watermark_logo'] ?? ''));
|
||||
$pdo->prepare("UPDATE tenant_settings SET pdf_watermark_logo = '' WHERE tenant_id = ?")->execute([$tenantIdLogo]);
|
||||
@@ -38,6 +67,7 @@ if ($canManageSettings) {
|
||||
$errors = $result['errors'];
|
||||
$settings = [
|
||||
'name' => $_POST['tenant_name'] ?? '',
|
||||
'customer_type' => $_POST['customer_type'] ?? 'unknown',
|
||||
'timezone' => $_POST['timezone'] ?? 'Europe/Berlin',
|
||||
'locale' => $_POST['locale'] ?? 'de-DE',
|
||||
'currency_code' => $_POST['currency_code'] ?? 'EUR',
|
||||
@@ -59,6 +89,9 @@ if ($canManageSettings) {
|
||||
'pdf_footer_text' => $_POST['pdf_footer_text'] ?? '',
|
||||
'payment_reminder_enabled' => !empty($_POST['payment_reminder_enabled']) ? 1 : 0,
|
||||
'payment_reminder_interval_days' => $_POST['payment_reminder_interval_days'] ?? 7,
|
||||
'brand_color' => $_POST['brand_color'] ?? '',
|
||||
'brand_logo' => (string)(saas_fetch_tenant_settings($pdo, (int)$user['tenant_id'])['brand_logo'] ?? ''),
|
||||
'watermark_enabled' => !empty($_POST['watermark_enabled']) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
@@ -105,6 +138,14 @@ include 'nav.php';
|
||||
<label for="tenant_name">Kundenname</label>
|
||||
<input type="text" name="tenant_name" id="tenant_name" value="<?php echo saas_html($settings['name']); ?>" required>
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="customer_type">Vertragstyp</label>
|
||||
<select name="customer_type" id="customer_type" required>
|
||||
<option value=""<?php echo $settings['customer_type'] === 'unknown' ? ' selected' : ''; ?>>Bitte auswählen</option>
|
||||
<option value="business"<?php echo $settings['customer_type'] === 'business' ? ' selected' : ''; ?>>Unternehmen / selbstständige Tätigkeit</option>
|
||||
<option value="consumer"<?php echo $settings['customer_type'] === 'consumer' ? ' selected' : ''; ?>>Verbraucher / private Nutzung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="timezone">Zeitzone</label>
|
||||
<input type="text" name="timezone" id="timezone" value="<?php echo saas_html($settings['timezone']); ?>" required>
|
||||
@@ -200,6 +241,54 @@ include 'nav.php';
|
||||
<label for="pdf_footer_text">Hinweis in der Fußzeile des Ausdrucks (leer = keine Fußzeile)</label>
|
||||
<input type="text" name="pdf_footer_text" id="pdf_footer_text" maxlength="500" value="<?php echo saas_html($settings['pdf_footer_text']); ?>">
|
||||
</div>
|
||||
<?php if ($brandingEnabled): ?>
|
||||
<div class="col-12">
|
||||
<h3>Eigenes Design</h3>
|
||||
<p>Logo und Akzentfarbe für die Oberfläche eurer Kaffeeliste. Leere Felder =
|
||||
Standarddesign. Das Logo hier erscheint sichtbar in der Kopfzeile jeder Seite –
|
||||
das Wasserzeichen weiter oben betrifft nur den Ausdruck.</p>
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="brand_color">Akzentfarbe</label>
|
||||
<input type="color" name="brand_color" id="brand_color"
|
||||
value="<?php echo saas_html($settings['brand_color'] !== '' ? $settings['brand_color'] : '#38761d'); ?>">
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="brand_logo_file">Logo für die Kopfzeile (PNG/JPEG)</label>
|
||||
<input type="file" name="brand_logo_file" id="brand_logo_file" accept="image/png,image/jpeg">
|
||||
<?php if (trim((string)($settings['brand_logo'] ?? '')) !== ''): ?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="brand_logo_remove" id="brand_logo_remove" value="1">
|
||||
<label class="form-check-label" for="brand_logo_remove">Hinterlegtes Logo entfernen</label>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<small>Aktuell kein Logo hinterlegt.</small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
// Der Hinweis auf die Kaffeeliste (Logo unten rechts) ist im kostenlosen
|
||||
// Tarif Teil des Angebots und deshalb dort festgestellt. Das Kaestchen bleibt
|
||||
// sichtbar, damit erkennbar ist, was passiert - nur eben unveraenderlich.
|
||||
$watermarkErzwungen = (string)($billing['plan_code'] ?? 'free') === 'free';
|
||||
$watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1) === 1;
|
||||
?>
|
||||
<div class="col-12">
|
||||
<h3>Hinweis auf die Kaffeeliste</h3>
|
||||
<p>Unten rechts steht eine kleine Marke mit dem Logo der Kaffeeliste und
|
||||
einem Link auf unsere Seite.</p>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="checkbox" id="watermark_enabled" name="watermark_enabled" value="1"
|
||||
<?php echo $watermarkAn ? 'checked' : ''; ?>
|
||||
<?php echo $watermarkErzwungen ? 'disabled' : ''; ?>>
|
||||
<label for="watermark_enabled">Logo der Kaffeeliste unten rechts anzeigen</label>
|
||||
<?php if ($watermarkErzwungen): ?>
|
||||
<div class="hint-box"><p>Im kostenlosen Tarif bleibt dieser Hinweis eingeblendet –
|
||||
er ist der Gegenwert für die kostenlose Nutzung. Ab dem Tarif „Basic" könnt ihr
|
||||
ihn hier ausschalten.</p></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<input type="checkbox" id="payment_reminder_enabled" name="payment_reminder_enabled" value="1" <?php echo (int)$settings['payment_reminder_enabled'] === 1 ? 'checked' : ''; ?>>
|
||||
<label for="payment_reminder_enabled">Automatische Zahlungserinnerung ab Warnschwelle</label>
|
||||
@@ -219,12 +308,20 @@ include 'nav.php';
|
||||
<p>
|
||||
Aktueller Tarif: <b><?php echo saas_html($billingPlans[$billingCheck['current_plan']]['label'] ?? $billingCheck['current_plan']); ?></b><br>
|
||||
Aktive Teilnehmer: <?php echo (int)$billingCheck['active_participants']; ?>
|
||||
<?php if (!empty($user['contract_ends_at'])): ?>
|
||||
<br><b>Vertrag gekündigt:</b> Der Zugang endet am <?php echo saas_html(date('d.m.Y, H:i', strtotime((string)$user['contract_ends_at']))); ?> Uhr. Bitte lade vorher den <a href="datenexport.php">Datenexport</a> herunter.
|
||||
<?php elseif (($billing['subscription_status'] ?? '') === 'canceling'): ?>
|
||||
<br><b>Gekündigt:</b> Der kostenpflichtige Tarif endet <?php echo !empty($billing['current_period_end']) ? 'am ' . saas_html(date('d.m.Y, H:i', strtotime((string)$billing['current_period_end']))) . ' Uhr' : 'zum Ende der laufenden Abrechnungsperiode'; ?>. Danach gilt der kostenlose Tarif.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if (isset($_GET['upgrade']) && $_GET['upgrade'] === 'success'): ?>
|
||||
<div class="hint-box success"><p>Danke! Die Änderung wird verarbeitet, der Tarif ist gleich aktuell.</p></div>
|
||||
<?php elseif (isset($_GET['upgrade']) && $_GET['upgrade'] === 'cancelled'): ?>
|
||||
<div class="hint-box error"><p>Der Bezahlvorgang wurde abgebrochen, es wurde nichts geändert.</p></div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($_GET['contract_mail_failed'])): ?>
|
||||
<div class="hint-box error"><p>Die Tarifänderung wurde ausgeführt, aber die Vertragsbestätigung konnte nicht per E-Mail versendet werden. Bitte speichere <a href="legal/agb-<?php echo saas_html(APP_TERMS_VERSION); ?>.txt" download>die AGB</a> und bei privater Nutzung <a href="legal/widerruf-<?php echo saas_html(APP_WITHDRAWAL_VERSION); ?>.txt" download>die Widerrufsbelehrung</a> und melde dich über das <a href="<?php echo saas_html(app_legal_ticket_url()); ?>">Ticketsystem</a>.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($billingCheck['needs_upgrade']): ?>
|
||||
<div class="hint-box error">
|
||||
@@ -264,15 +361,15 @@ include 'nav.php';
|
||||
<?php if ($isCurrent): ?>
|
||||
—
|
||||
<?php elseif (!$isSelfService): ?>
|
||||
<a href="mailto:info@ctb-it.de" class="button small">Anfragen</a>
|
||||
<a href="<?php echo saas_html(app_legal_ticket_url()); ?>" class="button small">Anfragen</a>
|
||||
<?php elseif (!$selectable): ?>
|
||||
<button type="button" class="button small" disabled>Nicht wählbar</button>
|
||||
<?php else: ?>
|
||||
<form method="post" action="abo-upgrade.php" onsubmit="return confirm('Tarif wirklich auf ' + <?php echo json_encode($plan['label']); ?> + ' ändern?');">
|
||||
<form method="post" action="<?php echo $planCode === 'free' ? 'abo-upgrade.php' : 'abo-bestellen.php'; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="plan_code" value="<?php echo saas_html($planCode); ?>">
|
||||
<button type="submit" class="button small primary">
|
||||
<?php echo $planCode === 'free' ? 'Kündigen / auf Kostenlos wechseln' : 'Buchen'; ?>
|
||||
<?php echo $planCode === 'free' ? 'Kostenpflichtiges Abo kündigen' : 'Bestellung prüfen'; ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
@@ -280,8 +377,7 @@ include 'nav.php';
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<p><small>Ein Wechsel zwischen bezahlten Tarifen wird bei Stripe anteilig verrechnet (Proration) und
|
||||
gilt sofort. Details zu den Tarifgrenzen stehen unter <a href="preise.php">Preise</a>.</small></p>
|
||||
<p><small>Bei einem Wechsel zwischen bezahlten Tarifen gilt die neue Teilnehmergrenze sofort; der neue Monatspreis wird ab der nächsten Verlängerung berechnet. Die Kündigung zum kostenlosen Tarif wirkt zum Ende der laufenden Abrechnungsperiode. Details stehen unter <a href="preise.php">Preise</a>.</small></p>
|
||||
|
||||
<?php if (($billing['stripe_customer_id'] ?? null) !== null): ?>
|
||||
<form method="post" action="abo-portal.php">
|
||||
|
||||
+54
-6
@@ -2,6 +2,8 @@
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/ledger.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/tenant-logo.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
@@ -13,27 +15,65 @@ if (!saas_user_has_role('owner', $user)) {
|
||||
|
||||
$tenantId = (int)$user['tenant_id'];
|
||||
$isDefaultTenant = ledger_is_default_tenant($pdo, $tenantId);
|
||||
$billing = billing_fetch_or_init($pdo, $tenantId);
|
||||
$hasRunningPaidSubscription = (string)$billing['plan_code'] !== 'free'
|
||||
&& (string)$billing['subscription_status'] !== 'canceled';
|
||||
$errors = [];
|
||||
$confirmInput = '';
|
||||
|
||||
if (!$isDefaultTenant && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!$isDefaultTenant && !$hasRunningPaidSubscription && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
$confirmInput = trim((string)($_POST['confirm_slug'] ?? ''));
|
||||
|
||||
if ($confirmInput !== $user['tenant_slug']) {
|
||||
$errors[] = 'Bitte das Kundenkürzel exakt eingeben, um die Löschung zu bestätigen.';
|
||||
} else {
|
||||
// Loeschen der tenants-Zeile kaskadiert per FK auf alles
|
||||
// tenant-scoped (participants, ledger_entries, notices,
|
||||
// tenant_memberships, audit_log, outbound_emails,
|
||||
// payment_import_batches/rows, tenant_settings). users bleiben
|
||||
// erhalten, da ein Login-Konto zu mehreren Mandanten gehoeren kann.
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId) ?? [];
|
||||
$logoFiles = [
|
||||
(string)($settings['brand_logo'] ?? ''),
|
||||
(string)($settings['pdf_watermark_logo'] ?? ''),
|
||||
];
|
||||
$memberStmt = $pdo->prepare('SELECT user_id FROM tenant_memberships WHERE tenant_id = ?');
|
||||
$memberStmt->execute([$tenantId]);
|
||||
$userIds = array_map('intval', $memberStmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
// Tenant-bezogene Tabellen werden über ihre Fremdschlüssel
|
||||
// kaskadierend gelöscht. Rechtserklärungen bleiben entkoppelt als
|
||||
// notwendiger Vertragsnachweis erhalten.
|
||||
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM tenant_memberships WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$membershipCount = (int)$stmt->fetchColumn();
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM platform_admins WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$isPlatformAdmin = (int)$stmt->fetchColumn() > 0;
|
||||
if ($membershipCount === 0 && !$isPlatformAdmin) {
|
||||
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
|
||||
}
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$errors[] = 'Die Löschung konnte nicht vollständig ausgeführt werden. Bitte versuche es erneut oder nutze das Ticketsystem.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
// Das Formular mit Fehlermeldung erneut anzeigen.
|
||||
} else {
|
||||
foreach ($logoFiles as $logoFile) {
|
||||
tenant_logo_delete($logoFile);
|
||||
}
|
||||
saas_logout();
|
||||
header('Location: landing.php?tenant_deleted=1');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
@@ -50,6 +90,14 @@ include 'nav.php';
|
||||
selbstständig gelöscht werden. Bitte wende dich für diesen Fall an den
|
||||
Betreiber.</p>
|
||||
</div>
|
||||
<?php elseif ($hasRunningPaidSubscription): ?>
|
||||
<div class="hint-box error">
|
||||
<p>Der Mandant hat noch einen laufenden kostenpflichtigen Tarif. Kündige
|
||||
ihn zuerst unter <a href="mandant-einstellungen.php">Mandant-Einstellungen</a>.
|
||||
Der Tarif bleibt bis zum Ende der bereits bezahlten Periode nutzbar;
|
||||
anschließend kannst du den Mandanten und seine operativen Daten hier
|
||||
endgültig löschen.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="hint-box error">
|
||||
<p><b>Diese Aktion kann nicht rückgängig gemacht werden.</b> Alle Daten von
|
||||
|
||||
+211
-12
@@ -33,6 +33,66 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUser)) {
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
function mitglieder_parse_filters(array $source): array
|
||||
{
|
||||
$status = (string)($source['status'] ?? 'alle');
|
||||
if (!in_array($status, ['alle', 'aktiv', 'inaktiv'], true)) {
|
||||
$status = 'alle';
|
||||
}
|
||||
|
||||
$zugang = (string)($source['zugang'] ?? 'alle');
|
||||
if (!in_array($zugang, ['alle', 'mit_zugang', 'ohne_zugang', 'entzogen'], true)) {
|
||||
$zugang = 'alle';
|
||||
}
|
||||
|
||||
$paypal = (string)($source['paypal'] ?? 'alle');
|
||||
if (!in_array($paypal, ['alle', 'hinterlegt', 'fehlt', 'doppelt'], true)) {
|
||||
$paypal = 'alle';
|
||||
}
|
||||
|
||||
return [
|
||||
'q' => trim((string)($source['q'] ?? '')),
|
||||
'status' => $status,
|
||||
'zugang' => $zugang,
|
||||
'paypal' => $paypal,
|
||||
];
|
||||
}
|
||||
|
||||
function mitglieder_build_filter_query(array $filters): string
|
||||
{
|
||||
$params = [];
|
||||
foreach (['q', 'status', 'zugang', 'paypal'] as $key) {
|
||||
$value = (string)($filters[$key] ?? '');
|
||||
if ($value !== '' && !in_array($value, ['alle'], true)) {
|
||||
$params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return http_build_query($params);
|
||||
}
|
||||
|
||||
function mitglieder_filter_fields(array $filters): string
|
||||
{
|
||||
return '<input type="hidden" name="filter_q" value="' . saas_html((string)$filters['q']) . '">'
|
||||
. '<input type="hidden" name="filter_status" value="' . saas_html((string)$filters['status']) . '">'
|
||||
. '<input type="hidden" name="filter_zugang" value="' . saas_html((string)$filters['zugang']) . '">'
|
||||
. '<input type="hidden" name="filter_paypal" value="' . saas_html((string)$filters['paypal']) . '">';
|
||||
}
|
||||
|
||||
function mitglieder_status_badge(bool $active): string
|
||||
{
|
||||
return '<span class="status-badge ' . ($active ? 'success' : 'muted') . '">' . ($active ? 'Aktiv' : 'Inaktiv') . '</span>';
|
||||
}
|
||||
|
||||
function mitglieder_access_label(array $mitglied, array $rollenLabels): string
|
||||
{
|
||||
if ($mitglied['membership_status'] === 'active') {
|
||||
return $rollenLabels[$mitglied['role']] ?? (string)$mitglied['role'];
|
||||
}
|
||||
|
||||
return $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang';
|
||||
}
|
||||
|
||||
if($hasAccess){
|
||||
|
||||
$meldung = null;
|
||||
@@ -40,6 +100,17 @@ if($hasAccess){
|
||||
$einladungslink = null;
|
||||
$bearbeitenId = null;
|
||||
$actorUserId = $saasUser['user_id'] ?? null;
|
||||
$filterSource = $_SERVER['REQUEST_METHOD'] === 'POST'
|
||||
? [
|
||||
'q' => $_POST['filter_q'] ?? '',
|
||||
'status' => $_POST['filter_status'] ?? 'alle',
|
||||
'zugang' => $_POST['filter_zugang'] ?? 'alle',
|
||||
'paypal' => $_POST['filter_paypal'] ?? 'alle',
|
||||
]
|
||||
: $_GET;
|
||||
$filter = mitglieder_parse_filters($filterSource);
|
||||
$filterQuery = mitglieder_build_filter_query($filter);
|
||||
$filterSuffix = $filterQuery !== '' ? '?' . $filterQuery : '';
|
||||
|
||||
if (isset($_SESSION['flash_mitglieder']) && is_array($_SESSION['flash_mitglieder'])) {
|
||||
$flash = $_SESSION['flash_mitglieder'];
|
||||
@@ -224,7 +295,7 @@ if($hasAccess){
|
||||
'fehler' => $fehler,
|
||||
'einladungslink' => $einladungslink,
|
||||
];
|
||||
header('Location: mitarbeiterverwalten.php');
|
||||
header('Location: mitarbeiterverwalten.php' . $filterSuffix);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -235,6 +306,77 @@ if($hasAccess){
|
||||
$billingPlan = billing_plans()[$billingInfo['plan_code']] ?? null;
|
||||
$aktiveMitgliederAnzahl = billing_count_active_participants($pdo, $tenantId);
|
||||
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
|
||||
$paypalNameCounts = [];
|
||||
foreach ($mitglieder as $mitglied) {
|
||||
$paypalNorm = imports_normalize_match_text((string)($mitglied['paypal_name'] ?? ''));
|
||||
if ($paypalNorm !== '') {
|
||||
$paypalNameCounts[$paypalNorm] = ($paypalNameCounts[$paypalNorm] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
$mitgliederStats = [
|
||||
'gesamt' => count($mitglieder),
|
||||
'aktiv' => 0,
|
||||
'inaktiv' => 0,
|
||||
'mit_zugang' => 0,
|
||||
'ohne_zugang' => 0,
|
||||
'paypal_fehlt' => 0,
|
||||
'paypal_doppelt' => 0,
|
||||
];
|
||||
$gefilterteMitglieder = [];
|
||||
foreach ($mitglieder as $mitglied) {
|
||||
$isActive = (bool)$mitglied['active'];
|
||||
$hasAccessAccount = $mitglied['membership_status'] === 'active';
|
||||
$paypalName = trim((string)($mitglied['paypal_name'] ?? ''));
|
||||
$paypalNorm = imports_normalize_match_text($paypalName);
|
||||
$paypalDuplicate = $paypalNorm !== '' && ($paypalNameCounts[$paypalNorm] ?? 0) > 1;
|
||||
|
||||
$mitgliederStats[$isActive ? 'aktiv' : 'inaktiv']++;
|
||||
$mitgliederStats[$hasAccessAccount ? 'mit_zugang' : 'ohne_zugang']++;
|
||||
if ($paypalName === '') {
|
||||
$mitgliederStats['paypal_fehlt']++;
|
||||
}
|
||||
if ($paypalDuplicate) {
|
||||
$mitgliederStats['paypal_doppelt']++;
|
||||
}
|
||||
|
||||
$matches = true;
|
||||
$q = imports_normalize_match_text((string)$filter['q']);
|
||||
if ($q !== '') {
|
||||
$searchText = imports_normalize_match_text(
|
||||
(string)$mitglied['display_name'] . ' ' . (string)($mitglied['email'] ?? '') . ' ' . $paypalName
|
||||
);
|
||||
$matches = str_contains($searchText, $q);
|
||||
}
|
||||
if ($matches && $filter['status'] === 'aktiv') {
|
||||
$matches = $isActive;
|
||||
}
|
||||
if ($matches && $filter['status'] === 'inaktiv') {
|
||||
$matches = !$isActive;
|
||||
}
|
||||
if ($matches && $filter['zugang'] === 'mit_zugang') {
|
||||
$matches = $hasAccessAccount;
|
||||
}
|
||||
if ($matches && $filter['zugang'] === 'ohne_zugang') {
|
||||
$matches = $mitglied['membership_status'] === null || $mitglied['membership_status'] === 'pending';
|
||||
}
|
||||
if ($matches && $filter['zugang'] === 'entzogen') {
|
||||
$matches = $mitglied['membership_status'] === 'revoked';
|
||||
}
|
||||
if ($matches && $filter['paypal'] === 'hinterlegt') {
|
||||
$matches = $paypalName !== '';
|
||||
}
|
||||
if ($matches && $filter['paypal'] === 'fehlt') {
|
||||
$matches = $paypalName === '';
|
||||
}
|
||||
if ($matches && $filter['paypal'] === 'doppelt') {
|
||||
$matches = $paypalDuplicate;
|
||||
}
|
||||
|
||||
if ($matches) {
|
||||
$mitglied['paypal_duplicate'] = $paypalDuplicate;
|
||||
$gefilterteMitglieder[] = $mitglied;
|
||||
}
|
||||
}
|
||||
$bearbeitenMitglied = null;
|
||||
if ($bearbeitenId !== null) {
|
||||
foreach ($mitglieder as $m) {
|
||||
@@ -263,12 +405,21 @@ if($hasAccess){
|
||||
Limit erreicht – für weitere aktive Mitglieder ist ein <a href="mandant-einstellungen.php">Upgrade</a> nötig.
|
||||
<?php endif; ?></p>
|
||||
|
||||
<div class="admin-summary-grid">
|
||||
<div><strong><?php echo (int)$mitgliederStats['gesamt']; ?></strong><span>Mitglieder</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['aktiv']; ?></strong><span>aktiv</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['mit_zugang']; ?></strong><span>mit Zugang</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['paypal_fehlt']; ?></strong><span>ohne PayPal-Name</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['paypal_doppelt']; ?></strong><span>PayPal-Konflikte</span></div>
|
||||
</div>
|
||||
|
||||
<?php if ($bearbeitenMitglied !== null): ?>
|
||||
<h3>Bearbeiten von <?php echo saas_html($bearbeitenMitglied['display_name']); ?></h3>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<input type="hidden" name="aktion" value="bearbeitenspeichern">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $bearbeitenMitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" name="name" id="name" value="<?php echo saas_html($bearbeitenMitglied['display_name']); ?>" required>
|
||||
@@ -292,6 +443,7 @@ if($hasAccess){
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<input type="hidden" name="aktion" id="aktion" value="anlegen">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
|
||||
<label for="new-name">Name:</label>
|
||||
<input type="text" name="name" id="new-name" required>
|
||||
@@ -316,6 +468,7 @@ if($hasAccess){
|
||||
<p><a class="button" href="mitglieder-vorlage.php">Vorlage herunterladen</a></p>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" enctype="multipart/form-data">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<input type="hidden" name="aktion" value="csv_import">
|
||||
<label for="mitglieder_csv">CSV-Datei mit Mitgliedern:</label>
|
||||
<input type="file" name="mitglieder_csv" id="mitglieder_csv" accept=".csv" required>
|
||||
@@ -326,8 +479,38 @@ if($hasAccess){
|
||||
<p>Name und E-Mail dienen der Kaffeeliste und Benachrichtigungen. Ein Login-Zugang
|
||||
ist davon unabhängig und wird separat je Mitglied gewährt oder entzogen.</p>
|
||||
|
||||
<form method="get" action="mitarbeiterverwalten.php" class="admin-filter-bar">
|
||||
<label for="mitglieder-q">Suche</label>
|
||||
<input type="search" name="q" id="mitglieder-q" value="<?php echo saas_html((string)$filter['q']); ?>" placeholder="Name, E-Mail oder PayPal">
|
||||
<label for="mitglieder-status">Status</label>
|
||||
<select name="status" id="mitglieder-status">
|
||||
<option value="alle" <?php echo $filter['status'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
|
||||
<option value="aktiv" <?php echo $filter['status'] === 'aktiv' ? 'selected' : ''; ?>>Aktiv</option>
|
||||
<option value="inaktiv" <?php echo $filter['status'] === 'inaktiv' ? 'selected' : ''; ?>>Inaktiv</option>
|
||||
</select>
|
||||
<label for="mitglieder-zugang">Zugang</label>
|
||||
<select name="zugang" id="mitglieder-zugang">
|
||||
<option value="alle" <?php echo $filter['zugang'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
|
||||
<option value="mit_zugang" <?php echo $filter['zugang'] === 'mit_zugang' ? 'selected' : ''; ?>>Mit Zugang</option>
|
||||
<option value="ohne_zugang" <?php echo $filter['zugang'] === 'ohne_zugang' ? 'selected' : ''; ?>>Ohne Zugang</option>
|
||||
<option value="entzogen" <?php echo $filter['zugang'] === 'entzogen' ? 'selected' : ''; ?>>Entzogen</option>
|
||||
</select>
|
||||
<label for="mitglieder-paypal">PayPal</label>
|
||||
<select name="paypal" id="mitglieder-paypal">
|
||||
<option value="alle" <?php echo $filter['paypal'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
|
||||
<option value="hinterlegt" <?php echo $filter['paypal'] === 'hinterlegt' ? 'selected' : ''; ?>>Hinterlegt</option>
|
||||
<option value="fehlt" <?php echo $filter['paypal'] === 'fehlt' ? 'selected' : ''; ?>>Fehlt</option>
|
||||
<option value="doppelt" <?php echo $filter['paypal'] === 'doppelt' ? 'selected' : ''; ?>>Konflikte</option>
|
||||
</select>
|
||||
<button type="submit">Filtern</button>
|
||||
<a class="button alt" href="mitarbeiterverwalten.php">Zurücksetzen</a>
|
||||
</form>
|
||||
|
||||
<p><?php echo count($gefilterteMitglieder); ?> von <?php echo count($mitglieder); ?> Mitgliedern angezeigt.</p>
|
||||
|
||||
<!-- Tabelle zur Anzeige und Bearbeitung von Mitgliedern -->
|
||||
<table class="table table-striped">
|
||||
<div class="table-wrapper">
|
||||
<table class="table table-striped admin-table">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
@@ -336,22 +519,28 @@ if($hasAccess){
|
||||
<th>Zugang</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
<?php foreach ($mitglieder as $mitglied): ?>
|
||||
<?php foreach ($gefilterteMitglieder as $mitglied): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($mitglied['display_name']); ?></td>
|
||||
<td><?php echo saas_html($mitglied['email'] ?? ''); ?></td>
|
||||
<td><?php echo saas_html($mitglied['paypal_name'] ?? ''); ?></td>
|
||||
<td><?php echo $mitglied['active'] ? '1' : '0'; ?></td>
|
||||
<td>
|
||||
<?php echo saas_html($mitglied['paypal_name'] ?? ''); ?>
|
||||
<?php if (!empty($mitglied['paypal_duplicate'])): ?>
|
||||
<br><span class="status-badge warning">Konflikt</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo mitglieder_status_badge((bool)$mitglied['active']); ?></td>
|
||||
<td>
|
||||
<?php if ($mitglied['membership_status'] === 'active'): ?>
|
||||
<?php if ($mitglied['role'] === 'owner'): ?>
|
||||
Inhaber
|
||||
<span class="status-badge info">Inhaber</span>
|
||||
<?php else: ?>
|
||||
<?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
|
||||
<span class="status-badge success"><?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?></span>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
|
||||
<input type="hidden" name="aktion" value="zugang_gewaehren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<select name="rolle">
|
||||
<?php foreach (saas_grantable_roles() as $rolle): ?>
|
||||
<option value="<?php echo saas_html($rolle); ?>" <?php echo $rolle === $mitglied['role'] ? 'selected' : ''; ?>><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
|
||||
@@ -359,20 +548,22 @@ if($hasAccess){
|
||||
</select>
|
||||
<button type="submit">Rolle ändern</button>
|
||||
</form>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
|
||||
<input type="hidden" name="aktion" value="zugang_entziehen">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Zugang entziehen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<?php echo $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang'; ?>
|
||||
<span class="status-badge <?php echo $mitglied['membership_status'] === 'revoked' ? 'warning' : 'muted'; ?>"><?php echo saas_html(mitglieder_access_label($mitglied, $rollenLabels)); ?></span>
|
||||
<?php if ($mitglied['email'] !== null && $mitglied['email'] !== ''): ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
|
||||
<input type="hidden" name="aktion" value="zugang_gewaehren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<select name="rolle">
|
||||
<?php foreach (saas_grantable_roles() as $rolle): ?>
|
||||
<option value="<?php echo saas_html($rolle); ?>"><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
|
||||
@@ -384,12 +575,13 @@ if($hasAccess){
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="actions">
|
||||
<ul class="actions compact-actions">
|
||||
<li>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<input type="hidden" name="aktion" value="bearbeiten">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Bearbeiten</button>
|
||||
</form>
|
||||
</li>
|
||||
@@ -399,6 +591,7 @@ if($hasAccess){
|
||||
<input type="hidden" name="aktion" value="deaktivieren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Deaktivieren</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
@@ -406,6 +599,7 @@ if($hasAccess){
|
||||
<input type="hidden" name="aktion" value="aktivieren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Aktivieren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
@@ -416,6 +610,7 @@ if($hasAccess){
|
||||
<input type="hidden" name="aktion" value="anonymisieren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Anonymisieren</button>
|
||||
</form>
|
||||
</li>
|
||||
@@ -424,7 +619,11 @@ if($hasAccess){
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($gefilterteMitglieder === []): ?>
|
||||
<tr><td colspan="6">Keine Mitglieder für diese Filter gefunden.</td></tr>
|
||||
<?php endif; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
}else{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app/analytics-relay.php';
|
||||
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
header('Allow: POST');
|
||||
http_response_code(405);
|
||||
exit;
|
||||
}
|
||||
|
||||
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||
if ($contentLength <= 0 || $contentLength > 8192) {
|
||||
http_response_code(413);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$payload = is_string($rawBody) ? json_decode($rawBody, true) : null;
|
||||
if (!is_array($payload)) {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var array<string, string> $cookies */
|
||||
$cookies = $_COOKIE;
|
||||
$parameters = app_matomo_page_view_parameters($payload, $_SERVER, $cookies);
|
||||
} catch (Throwable) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = app_matomo_forward_page_view($parameters);
|
||||
if (!$result['ok']) {
|
||||
error_log('First-Party-Matomo-Tracking fehlgeschlagen (HTTP ' . $result['status'] . ').');
|
||||
http_response_code(502);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(204);
|
||||
+11
-6
@@ -6,7 +6,6 @@ require_once __DIR__ . '/app/rate-limit.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$email = trim((string)($_POST['email'] ?? ''));
|
||||
$tenantSlug = trim((string)($_POST['tenant_slug'] ?? ''));
|
||||
$errors = [];
|
||||
$message = '';
|
||||
$resetLink = null;
|
||||
@@ -22,7 +21,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// ausgereiztes Limit nicht verrät, ob das Konto existiert.
|
||||
$message = 'Wenn ein passendes Konto existiert, wurde ein Link vorbereitet.';
|
||||
} else {
|
||||
$result = saas_request_password_reset($pdo, $email, $tenantSlug);
|
||||
// Ohne Kundenkuerzel: das Passwort haengt am Benutzerkonto, nicht am
|
||||
// Mandanten - ein Reset gilt daher ohnehin fuer alle Mandanten, zu
|
||||
// denen die E-Mail gehoert.
|
||||
$result = saas_request_password_reset($pdo, $email);
|
||||
|
||||
if ($result['ok']) {
|
||||
$message = $result['message'];
|
||||
@@ -48,6 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
@@ -93,10 +96,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" name="email" id="email" value="<?php echo saas_html($email); ?>" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="tenant_slug">Kundenkürzel</label>
|
||||
<input type="text" name="tenant_slug" id="tenant_slug" value="<?php echo saas_html($tenantSlug); ?>" placeholder="optional">
|
||||
</div>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit" class="primary">Link vorbereiten</button></li>
|
||||
@@ -112,9 +111,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
<a href="avv.php">AVV</a>
|
||||
<a href="widerruf.php">Widerrufsbelehrung</a>
|
||||
<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>
|
||||
<a href="preise.php">Preise</a>
|
||||
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||
<?php echo app_analytics_footer_settings_button_html(); ?>
|
||||
</footer>
|
||||
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
@@ -36,6 +36,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
@@ -105,9 +106,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
<a href="avv.php">AVV</a>
|
||||
<a href="widerruf.php">Widerrufsbelehrung</a>
|
||||
<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>
|
||||
<a href="preise.php">Preise</a>
|
||||
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||
<?php echo app_analytics_footer_settings_button_html(); ?>
|
||||
</footer>
|
||||
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
+140
-14
@@ -3,6 +3,7 @@
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/paypal-inbox.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -15,12 +16,88 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// Gesperrte Funktion: $hasAccess faellt zurueck, damit auch die
|
||||
// POST-Verarbeitung weiter unten nicht mehr laeuft. Der Menuepunkt ist in
|
||||
// diesem Fall ausgeblendet - die Seite bleibt aber ueber die direkte URL
|
||||
// erreichbar und muss deshalb selbst pruefen.
|
||||
$sperrHinweis = null;
|
||||
$restarbeitHinweis = false;
|
||||
if ($hasAccess && !app_feature_enabled($pdo, $tenantId, 'paypal_inbox')) {
|
||||
// Der Betreiber hat die Funktion fuer diesen Mandanten nicht freigeschaltet.
|
||||
$sperrHinweis = app_feature_notice_html('paypal_inbox');
|
||||
$hasAccess = false;
|
||||
} elseif ($hasAccess && !app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
||||
// Der Mandant selbst bietet PayPal nicht mehr als Zahlungsweg an. Offene
|
||||
// Zahlungen muessen trotzdem noch zugeordnet werden koennen - erst mit
|
||||
// leerer Warteschlange schliesst die Seite.
|
||||
if (paypal_count_unmatched($pdo, $tenantId) > 0) {
|
||||
$restarbeitHinweis = true;
|
||||
} else {
|
||||
$sperrHinweis = app_feature_tenant_notice_html('paypal_inbox');
|
||||
$hasAccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
$flash = null;
|
||||
if ($hasAccess && isset($_SESSION['flash_paypal'])) {
|
||||
$flash = $_SESSION['flash_paypal'];
|
||||
unset($_SESSION['flash_paypal']);
|
||||
}
|
||||
|
||||
$suchbegriff = trim((string)($_SERVER['REQUEST_METHOD'] === 'POST' ? ($_POST['return_q'] ?? '') : ($_GET['q'] ?? '')));
|
||||
$returnQuery = $suchbegriff !== '' ? '?q=' . urlencode($suchbegriff) : '';
|
||||
|
||||
function paypal_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
|
||||
{
|
||||
$suggestedIds = [];
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$suggestedIds[(int)$suggestion['participant_id']] = true;
|
||||
}
|
||||
|
||||
$html = '<option value="">- Mitglied wählen -</option>';
|
||||
if ($suggestions !== []) {
|
||||
$html .= '<optgroup label="Vorschläge">';
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$id = (int)$suggestion['participant_id'];
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$suggestion['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
}
|
||||
|
||||
$html .= '<optgroup label="Alle aktiven Mitglieder">';
|
||||
foreach ($members as $member) {
|
||||
$id = (int)$member['participant_id'];
|
||||
if (isset($suggestedIds[$id])) {
|
||||
continue;
|
||||
}
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$member['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
function paypal_payment_matches_query(array $payment, string $query): bool
|
||||
{
|
||||
$query = imports_normalize_match_text($query);
|
||||
if ($query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$text = imports_normalize_match_text(
|
||||
(string)$payment['payer_name'] . ' '
|
||||
. (string)($payment['payer_email'] ?? '') . ' '
|
||||
. (string)($payment['note'] ?? '') . ' '
|
||||
. (string)($payment['transaction_code'] ?? '')
|
||||
);
|
||||
|
||||
return str_contains($text, $query);
|
||||
}
|
||||
|
||||
if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$aktion = (string)($_POST['aktion'] ?? '');
|
||||
$paymentId = (int)($_POST['payment_id'] ?? 0);
|
||||
@@ -32,8 +109,16 @@ if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$flash = ['type' => 'error', 'text' => 'Bitte ein Mitglied auswählen.'];
|
||||
} else {
|
||||
$result = paypal_assign_payment($pdo, $tenantId, $paymentId, $participantId, $actorUserId);
|
||||
$erfolgstext = 'Zahlung wurde zugeordnet und als Einzahlung gebucht.';
|
||||
// Wurde dabei der PayPal-Name gelernt, gehoert das in die Meldung -
|
||||
// sonst wirkt die stillschweigende Aenderung am Mitglied wie Magie.
|
||||
if (($result['learned_paypal_name'] ?? null) !== null) {
|
||||
$erfolgstext .= ' Der PayPal-Name „' . $result['learned_paypal_name']
|
||||
. '" ist jetzt bei diesem Mitglied hinterlegt – künftige Zahlungen'
|
||||
. ' werden automatisch zugeordnet.';
|
||||
}
|
||||
$flash = $result['ok']
|
||||
? ['type' => 'success', 'text' => 'Zahlung wurde zugeordnet und als Einzahlung gebucht.']
|
||||
? ['type' => 'success', 'text' => $erfolgstext]
|
||||
: ['type' => 'error', 'text' => $result['error'] ?? 'Die Zuordnung ist fehlgeschlagen.'];
|
||||
}
|
||||
} elseif ($aktion === 'ignorieren') {
|
||||
@@ -45,7 +130,7 @@ if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
// Post-Redirect-Get gegen Doppelbuchung per Refresh.
|
||||
$_SESSION['flash_paypal'] = $flash;
|
||||
header('Location: paypal-zuordnung.php');
|
||||
header('Location: paypal-zuordnung.php' . $returnQuery);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -58,6 +143,7 @@ if ($hasAccess) {
|
||||
$offene = paypal_fetch_unmatched($pdo, $tenantId);
|
||||
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
}
|
||||
$offeneGefiltert = array_values(array_filter($offene, static fn(array $payment): bool => paypal_payment_matches_query($payment, $suchbegriff)));
|
||||
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -67,12 +153,21 @@ include "nav.php";
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<?php if ($sperrHinweis !== null): ?>
|
||||
<?php echo $sperrHinweis; ?>
|
||||
<?php elseif (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
<h2>PayPal-Zahlungen zuordnen</h2>
|
||||
|
||||
<?php if ($restarbeitHinweis): ?>
|
||||
<div class="hint-box warning"><p><b>PayPal ist in euren Mandant-Einstellungen deaktiviert.</b><br>
|
||||
Diese Seite bleibt geöffnet, bis die unten stehenden Zahlungen zugeordnet oder abgehakt sind.
|
||||
Danach verschwindet sie aus dem Menü – bis PayPal unter
|
||||
<a href="mandant-einstellungen.php">Mandant-Einstellungen</a> wieder eingeschaltet wird.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($flash !== null): ?>
|
||||
<div class="hint-box <?php echo $flash['type'] === 'success' ? 'success' : 'error'; ?>"><p><?php echo saas_html($flash['text']); ?></p></div>
|
||||
<?php endif; ?>
|
||||
@@ -88,27 +183,56 @@ include "nav.php";
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Offene Zahlungen (<?php echo count($offene); ?>)</h3>
|
||||
<form method="get" action="paypal-zuordnung.php" class="admin-filter-bar">
|
||||
<label for="paypal-q">Suche</label>
|
||||
<input type="search" name="q" id="paypal-q" value="<?php echo saas_html($suchbegriff); ?>" placeholder="Zahler, Mitteilung oder Transaktion">
|
||||
<button type="submit">Filtern</button>
|
||||
<a class="button alt" href="paypal-zuordnung.php">Zurücksetzen</a>
|
||||
</form>
|
||||
<?php if ($offene === []): ?>
|
||||
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet. 🎉</p>
|
||||
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet.</p>
|
||||
<?php elseif ($offeneGefiltert === []): ?>
|
||||
<p>Keine offenen Zahlungen für diesen Filter gefunden.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Eingang</th><th>Zahler (PayPal)</th><th>Mitteilung</th><th>Betrag</th><th>Zuordnen</th><th></th></tr>
|
||||
<?php foreach ($offene as $z): ?>
|
||||
<p><?php echo count($offeneGefiltert); ?> von <?php echo count($offene); ?> offenen Zahlungen angezeigt.</p>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr><th>Eingang</th><th>Zahlung</th><th>Betrag</th><th>Vorschläge</th><th>Zuordnen</th><th></th></tr>
|
||||
<?php foreach ($offeneGefiltert as $z): ?>
|
||||
<?php
|
||||
$suggestions = imports_suggest_participants($pdo, $tenantId, (string)$z['payer_name'], (string)($z['note'] ?? ''), 4);
|
||||
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo saas_html((string)($z['paid_at'] ?? $z['created_at'])); ?></td>
|
||||
<td><?php echo saas_html($z['payer_name']); ?></td>
|
||||
<td><?php echo saas_html((string)($z['note'] ?? '')); ?></td>
|
||||
<td>
|
||||
<strong><?php echo saas_html($z['payer_name']); ?></strong>
|
||||
<?php if (trim((string)($z['payer_email'] ?? '')) !== ''): ?>
|
||||
<br><small><?php echo saas_html((string)$z['payer_email']); ?></small>
|
||||
<?php endif; ?>
|
||||
<?php if (($z['note'] ?? '') !== null && trim((string)$z['note']) !== ''): ?>
|
||||
<br><small><?php echo saas_html((string)$z['note']); ?></small>
|
||||
<?php endif; ?>
|
||||
<br><small>Transaktion: <?php echo saas_html((string)$z['transaction_code']); ?></small>
|
||||
</td>
|
||||
<td><?php echo saas_html(saas_format_money_cents((int)$z['net_cents'])); ?> €</td>
|
||||
<td>
|
||||
<form method="post" action="paypal-zuordnung.php" style="display:flex;gap:0.5em;align-items:center;margin:0">
|
||||
<?php if ($suggestions === []): ?>
|
||||
<span class="status-badge muted">Kein Vorschlag</span>
|
||||
<?php else: ?>
|
||||
<?php foreach ($suggestions as $suggestion): ?>
|
||||
<span class="status-badge info"><?php echo saas_html((string)$suggestion['display_name']); ?></span>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="paypal-zuordnung.php" class="inline-admin-form">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zuordnen">
|
||||
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
|
||||
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
|
||||
<select name="participant_id" required>
|
||||
<option value="">– Mitglied wählen –</option>
|
||||
<?php foreach ($mitglieder as $m): ?>
|
||||
<option value="<?php echo (int)$m['participant_id']; ?>"><?php echo saas_html($m['display_name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php echo paypal_select_options($mitglieder, $suggestions, $selectedId); ?>
|
||||
</select>
|
||||
<button type="submit">Buchen</button>
|
||||
</form>
|
||||
@@ -118,12 +242,14 @@ include "nav.php";
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="ignorieren">
|
||||
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
|
||||
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
+19
-9
@@ -1,4 +1,8 @@
|
||||
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
|
||||
<?php
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
app_record_public_page_view('prices');
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
@@ -7,6 +11,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
@@ -52,9 +57,17 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Beim Überschreiten einer Stufe wird automatisch zur nächsthöheren
|
||||
gewechselt. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet – die
|
||||
genannten Beträge sind die tatsächlich zu zahlenden Endpreise.</p>
|
||||
<p>Es gibt keine automatische Hochstufung. Ist die Grenze erreicht,
|
||||
wählst du selbst einen passenden Tarif oder deaktivierst Teilnehmer.
|
||||
Gemäß § 19 UStG wird keine Umsatzsteuer berechnet – die genannten
|
||||
Beträge sind die tatsächlich zu zahlenden monatlichen Endpreise.</p>
|
||||
|
||||
<p>Alle Tarife laufen unbefristet und haben eine monatliche
|
||||
Abrechnungsperiode. Kostenpflichtige Tarife sind jederzeit zum Ende der
|
||||
laufenden Periode kündbar; es gibt keine längere Mindestlaufzeit. Zahlung
|
||||
erfolgt monatlich im Voraus über Stripe. Beim Tarifwechsel gilt die neue
|
||||
Teilnehmergrenze sofort und der neue volle Preis ab der nächsten
|
||||
Verlängerung.</p>
|
||||
|
||||
<ul class="actions">
|
||||
<li><a href="register.php" class="button primary big">Kostenlos einrichten</a></li>
|
||||
@@ -65,12 +78,9 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="public-footer-links">
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
</footer>
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = app_require_platform_admin($pdo);
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: backoffice.php');
|
||||
exit;
|
||||
}
|
||||
app_require_csrf();
|
||||
|
||||
$requestId = filter_var($_POST['request_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($requestId === false || $requestId <= 0) {
|
||||
http_response_code(400);
|
||||
exit('Ungültige Rechtserklärung.');
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT tenant_id, reference_code FROM legal_requests WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$requestId]);
|
||||
$request = $stmt->fetch();
|
||||
if ($request === false) {
|
||||
http_response_code(404);
|
||||
exit('Rechtserklärung nicht gefunden.');
|
||||
}
|
||||
$pdo->prepare("UPDATE legal_requests SET status = 'processed', processed_at = NOW() WHERE id = ?")
|
||||
->execute([$requestId]);
|
||||
app_audit_log(
|
||||
$pdo,
|
||||
$request['tenant_id'] !== null ? (int)$request['tenant_id'] : null,
|
||||
(int)$user['user_id'],
|
||||
'legal_request.processed',
|
||||
'legal_request',
|
||||
(int)$requestId,
|
||||
['reference_code' => (string)$request['reference_code']]
|
||||
);
|
||||
|
||||
header('Location: backoffice.php?legal_request=processed');
|
||||
exit;
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/app/database.php';
|
||||
require_once __DIR__ . '/app/legal-requests.php';
|
||||
|
||||
$token = (string)($_GET['token'] ?? '');
|
||||
$request = app_fetch_legal_request_by_token(app_db_pdo(), $token);
|
||||
if ($request === null) {
|
||||
http_response_code(404);
|
||||
exit('Bestätigung nicht gefunden.');
|
||||
}
|
||||
|
||||
$requestMetadata = json_decode((string)($request['metadata_json'] ?? ''), true);
|
||||
$input = [
|
||||
'request_type' => $request['request_type'],
|
||||
'requester_name' => $request['requester_name'],
|
||||
'requester_email' => $request['requester_email'],
|
||||
'contract_reference' => $request['contract_reference'],
|
||||
'request_reason' => $request['request_reason'],
|
||||
'cancellation_kind' => is_array($requestMetadata) && ($requestMetadata['cancellation_kind'] ?? null) === 'extraordinary' ? 'extraordinary' : 'ordinary',
|
||||
];
|
||||
$processing = [
|
||||
'effective_end' => is_array($requestMetadata) && !empty($requestMetadata['effective_end'])
|
||||
? (string)$requestMetadata['effective_end']
|
||||
: ($request['requested_end'] === 'earliest' ? 'zum frühestmöglichen Zeitpunkt' : (string)($request['requested_end'] ?? '')),
|
||||
'processing_note' => 'Die Erklärung wurde unter dem angegebenen Zeitpunkt gespeichert.',
|
||||
];
|
||||
$stored = ['reference_code' => $request['reference_code'], 'received_at' => $request['received_at']];
|
||||
$text = app_legal_request_confirmation_text($input, $stored, $processing);
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Content-Type: text/plain; charset=UTF-8');
|
||||
if (isset($_GET['download'])) {
|
||||
header('Content-Disposition: attachment; filename="kaffeeliste-bestaetigung-' . $request['reference_code'] . '.txt"');
|
||||
}
|
||||
}
|
||||
echo $text;
|
||||
+93
-5
@@ -3,6 +3,12 @@
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/saas-mail.php';
|
||||
require_once __DIR__ . '/app/rate-limit.php';
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
require_once __DIR__ . '/app/pushover.php';
|
||||
require_once __DIR__ . '/app/spam-guard.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
|
||||
app_record_public_page_view('register');
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$errors = [];
|
||||
@@ -11,13 +17,36 @@ $values = [
|
||||
'tenant_slug' => trim((string)($_POST['tenant_slug'] ?? '')),
|
||||
'display_name' => trim((string)($_POST['display_name'] ?? '')),
|
||||
'email' => trim((string)($_POST['email'] ?? '')),
|
||||
'customer_type' => (string)($_POST['customer_type'] ?? ''),
|
||||
];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
|
||||
if (!app_rate_limit_check($pdo, 'register_ip:' . app_client_ip(), 5, 3600)) {
|
||||
$errors = ['Zu viele Registrierungsversuche. Bitte versuche es später erneut.'];
|
||||
// Reihenfolge mit Absicht: erst die billigen Bot-Merkmale, dann die
|
||||
// Rate-Limits, erst danach die DNS-Abfrage. Alle Abweisungen bis
|
||||
// einschliesslich Rate-Limit nennen denselben generischen Text, damit die
|
||||
// Antwort nicht verraet, welche Huerde angeschlagen hat.
|
||||
$genericRejection = 'Zu viele Registrierungsversuche. Bitte versuche es später erneut.';
|
||||
|
||||
if (app_spam_guard_honeypot_tripped()) {
|
||||
app_spam_guard_log('register', 'honeypot');
|
||||
$errors = [$genericRejection];
|
||||
} elseif (app_spam_guard_submitted_too_fast('register')) {
|
||||
app_spam_guard_log('register', 'zu schnell abgesendet');
|
||||
$errors = [$genericRejection];
|
||||
} elseif (!app_rate_limit_check($pdo, 'register_ip:' . app_client_ip(), 5, 3600)) {
|
||||
$errors = [$genericRejection];
|
||||
} elseif (!app_rate_limit_check($pdo, 'register_global', APP_SPAM_GUARD_GLOBAL_MAX, APP_SPAM_GUARD_GLOBAL_WINDOW)) {
|
||||
// Notbremse gegen verteilte Bots: die kommen je ueber eine eigene IP,
|
||||
// an der das IP-Limit nie anschlaegt.
|
||||
app_spam_guard_log('register', 'globales Limit');
|
||||
$errors = [$genericRejection];
|
||||
} elseif (filter_var($values['email'], FILTER_VALIDATE_EMAIL) !== false
|
||||
&& !app_spam_guard_email_domain_resolves($values['email'])) {
|
||||
// Hier bewusst konkret: das ist typischerweise ein Tippfehler in der
|
||||
// Domain, und der Hinweis hilft einem echten Nutzer weiter.
|
||||
$errors = ['Zu dieser E-Mail-Adresse gibt es keine erreichbare Mail-Domain. Bitte prüfe die Schreibweise.'];
|
||||
} else {
|
||||
$result = saas_register_tenant_owner($pdo, [
|
||||
'tenant_name' => $values['tenant_name'],
|
||||
@@ -26,13 +55,41 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
'email' => $values['email'],
|
||||
'password' => (string)($_POST['password'] ?? ''),
|
||||
'password_confirm' => (string)($_POST['password_confirm'] ?? ''),
|
||||
'customer_type' => $values['customer_type'],
|
||||
'accept_terms' => !empty($_POST['accept_terms']),
|
||||
'acknowledge_privacy' => !empty($_POST['acknowledge_privacy']),
|
||||
'accept_dpa' => !empty($_POST['accept_dpa']),
|
||||
]);
|
||||
|
||||
if ($result['ok']) {
|
||||
$contractMail = saas_send_registration_contract_mail(
|
||||
$values['email'],
|
||||
$values['tenant_name'],
|
||||
(string)$result['identity']['tenant_slug'],
|
||||
$values['customer_type']
|
||||
);
|
||||
$verificationMail = ['ok' => true];
|
||||
if (!empty($result['email_verification_token'])) {
|
||||
saas_send_email_verification_mail($values['email'], (string)$result['email_verification_token']);
|
||||
$verificationMail = saas_send_email_verification_mail($values['email'], (string)$result['email_verification_token']);
|
||||
}
|
||||
// Betreiber-Push ueber die Neuanmeldung. Bewusst hier statt in
|
||||
// saas_register_tenant_owner(), damit die check-*-Skripte in
|
||||
// scripts/ keine Pushes ausloesen. Das Ergebnis wird nicht
|
||||
// ausgewertet: ein fehlgeschlagener Push darf die Registrierung
|
||||
// nie stoppen, er landet nur im PHP-Error-Log.
|
||||
pushover_notify_new_tenant(
|
||||
$values['tenant_name'],
|
||||
(string)$result['identity']['tenant_slug'],
|
||||
$values['email'],
|
||||
$values['customer_type']
|
||||
);
|
||||
saas_session_login($result['identity']);
|
||||
if (empty($contractMail['ok'])) {
|
||||
$_SESSION['saas_contract_mail_failed'] = true;
|
||||
}
|
||||
if (empty($verificationMail['ok'])) {
|
||||
$_SESSION['saas_verification_mail_failed'] = true;
|
||||
}
|
||||
if (saas_should_show_auth_links() && !empty($result['email_verification_token'])) {
|
||||
$_SESSION['saas_dev_email_verification_token'] = (string)$result['email_verification_token'];
|
||||
}
|
||||
@@ -54,6 +111,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
@@ -68,7 +126,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<div class="public-auth-grid">
|
||||
<div class="public-auth-copy">
|
||||
<h1>Registrierung</h1>
|
||||
<p>Lege einen neuen Kundenbereich an. Danach bist du als Owner angemeldet und kannst die Kaffeeliste für dein Team einrichten.</p>
|
||||
<p>Lege kostenlos einen neuen Kundenbereich an. Das Angebot kann privat oder geschäftlich genutzt werden.</p>
|
||||
</div>
|
||||
|
||||
<div class="public-panel">
|
||||
@@ -82,9 +140,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php app_spam_guard_mark_form_rendered('register'); ?>
|
||||
<form method="post" action="register.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo app_spam_guard_honeypot_field(); ?>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<fieldset>
|
||||
<legend>Du registrierst dich als</legend>
|
||||
<input type="radio" name="customer_type" id="customer_type_business" value="business"<?php echo $values['customer_type'] === 'business' ? ' checked' : ''; ?> required>
|
||||
<label for="customer_type_business">Unternehmen / selbstständige Tätigkeit</label>
|
||||
<input type="radio" name="customer_type" id="customer_type_consumer" value="consumer"<?php echo $values['customer_type'] === 'consumer' ? ' checked' : ''; ?> required>
|
||||
<label for="customer_type_consumer">Verbraucher / private Nutzung</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="tenant_name">Kundenname</label>
|
||||
<input type="text" name="tenant_name" id="tenant_name" value="<?php echo saas_html($values['tenant_name']); ?>" required>
|
||||
@@ -109,9 +178,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<label for="password_confirm">Passwort wiederholen</label>
|
||||
<input type="password" name="password_confirm" id="password_confirm" minlength="8" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="checkbox" name="accept_terms" id="accept_terms" value="1" required>
|
||||
<label for="accept_terms">Ich akzeptiere die <a href="agb.php" target="_blank" rel="noopener">AGB</a>.</label>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="checkbox" name="acknowledge_privacy" id="acknowledge_privacy" value="1" required>
|
||||
<label for="acknowledge_privacy">Ich habe die <a href="datenschutz.php" target="_blank" rel="noopener">Datenschutzerklärung</a> gelesen. Dies ist keine Einwilligung in optionale Werbung oder Tracking.</label>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="checkbox" name="accept_dpa" id="accept_dpa" value="1" required>
|
||||
<label for="accept_dpa">Ich vereinbare den <a href="avv.php" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag</a>, soweit ich Daten anderer Personen in der Kaffeeliste verwalte.</label>
|
||||
</div>
|
||||
</div>
|
||||
<p><small>Das Kundenkonto ist kostenlos. Verbraucher haben ein 14-tägiges <a href="widerruf.php">Widerrufsrecht</a>.</small></p>
|
||||
<ul class="actions">
|
||||
<li><button type="submit" class="primary">Kundenkonto anlegen</button></li>
|
||||
<li><button type="submit" class="primary">Kostenloses Kundenkonto anlegen</button></li>
|
||||
<li><a href="login.php" class="button">Zum Login</a></li>
|
||||
</ul>
|
||||
</form>
|
||||
@@ -124,9 +206,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
<a href="avv.php">AVV</a>
|
||||
<a href="widerruf.php">Widerrufsbelehrung</a>
|
||||
<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>
|
||||
<a href="preise.php">Preise</a>
|
||||
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||
<?php echo app_analytics_footer_settings_button_html(); ?>
|
||||
</footer>
|
||||
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
|
||||
@@ -5,6 +5,11 @@ Allow: /preise.php
|
||||
Allow: /impressum.php
|
||||
Allow: /agb.php
|
||||
Allow: /datenschutz.php
|
||||
Allow: /avv.php
|
||||
Allow: /widerruf.php
|
||||
Allow: /widerrufen.php
|
||||
Allow: /kuendigen.php
|
||||
Allow: /legal/
|
||||
Disallow: /index.php
|
||||
Disallow: /login.php
|
||||
Disallow: /register.php
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prueft den zweiten Faktor am echten Anmeldeweg: dass der Login mit
|
||||
* aktivem TOTP nicht durchgestellt wird, dass der Zwischenzustand keinerlei
|
||||
* Zugriff gewaehrt und dass erst der richtige Code die Sitzung herstellt.
|
||||
*
|
||||
* Braucht einen laufenden Webserver auf dem Projektverzeichnis:
|
||||
*
|
||||
* php -S 127.0.0.1:8080 -t .
|
||||
* php scripts/check-2fa-http-flow.php
|
||||
*
|
||||
* Abweichende Adresse ueber SMOKE_BASE_URL. Die algorithmische Seite deckt
|
||||
* scripts/check-totp-flow.php ohne Server ab.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/dev-db.php';
|
||||
require __DIR__ . '/../app/totp.php';
|
||||
|
||||
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
|
||||
|
||||
/**
|
||||
* @param array<string, string> $jar
|
||||
* @return array{status: int, location: ?string, body: string}
|
||||
*/
|
||||
function zfa_request(string $url, array &$jar, string $method = 'GET', ?string $body = null): array
|
||||
{
|
||||
$headers = ['Content-Type: application/x-www-form-urlencoded'];
|
||||
if ($jar !== []) {
|
||||
$pairs = [];
|
||||
foreach ($jar as $name => $value) {
|
||||
$pairs[] = "{$name}={$value}";
|
||||
}
|
||||
$headers[] = 'Cookie: ' . implode('; ', $pairs);
|
||||
}
|
||||
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => $method,
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $body,
|
||||
'timeout' => 15,
|
||||
'ignore_errors' => true,
|
||||
'follow_location' => 0,
|
||||
]]);
|
||||
|
||||
$responseBody = @file_get_contents($url, false, $context);
|
||||
$status = 0;
|
||||
$location = null;
|
||||
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
|
||||
$status = (int)$m[1];
|
||||
}
|
||||
if (stripos($header, 'Location:') === 0) {
|
||||
$location = trim(substr($header, 9));
|
||||
}
|
||||
if (stripos($header, 'Set-Cookie:') === 0 && preg_match('/^Set-Cookie:\s*([^=]+)=([^;]*)/i', $header, $m) === 1) {
|
||||
$jar[$m[1]] = $m[2];
|
||||
}
|
||||
}
|
||||
|
||||
return ['status' => $status, 'location' => $location, 'body' => (string)$responseBody];
|
||||
}
|
||||
|
||||
/** @param array<string, string> $jar */
|
||||
function zfa_csrf(string $url, array &$jar): string
|
||||
{
|
||||
$response = zfa_request($url, $jar);
|
||||
preg_match('/name="csrf_token" value="([^"]+)"/', $response['body'], $m);
|
||||
|
||||
return $m[1] ?? '';
|
||||
}
|
||||
|
||||
$failures = [];
|
||||
$passes = 0;
|
||||
|
||||
function zfa_assert(string $label, bool $condition, array &$failures, int &$passes): void
|
||||
{
|
||||
if ($condition) {
|
||||
$passes++;
|
||||
echo "PASS {$label}\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$failures[] = $label;
|
||||
echo "FAIL {$label}\n";
|
||||
}
|
||||
|
||||
$probe = [];
|
||||
$reachable = zfa_request("{$baseUrl}/login.php", $probe);
|
||||
if ($reachable['status'] !== 200) {
|
||||
fwrite(STDERR, "Kein Webserver unter {$baseUrl} erreichbar. Siehe Kopf dieser Datei.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pdo = dev_pdo();
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
$email = "zfacheck-{$suffix}@example.com";
|
||||
$password = 'geheim-genug-123';
|
||||
|
||||
$registration = saas_register_tenant_owner($pdo, [
|
||||
'tenant_name' => "2FA Check {$suffix}",
|
||||
'tenant_slug' => "zfacheck-{$suffix}",
|
||||
'display_name' => '2FA Checker',
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'password_confirm' => $password,
|
||||
'customer_type' => 'business',
|
||||
'accept_terms' => true,
|
||||
'acknowledge_privacy' => true,
|
||||
'accept_dpa' => true,
|
||||
]);
|
||||
|
||||
if (empty($registration['ok'])) {
|
||||
fwrite(STDERR, 'Setup fehlgeschlagen: ' . implode('; ', $registration['errors'] ?? []) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$userId = (int)$registration['identity']['user_id'];
|
||||
$tenantId = (int)$registration['identity']['tenant_id'];
|
||||
|
||||
try {
|
||||
// Ausgangslage: ohne zweiten Faktor fuehrt der Login direkt weiter.
|
||||
$jar = [];
|
||||
$csrf = zfa_csrf("{$baseUrl}/login.php", $jar);
|
||||
$login = zfa_request("{$baseUrl}/login.php", $jar, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf, 'email' => $email, 'password' => $password,
|
||||
]));
|
||||
zfa_assert('Ohne zweiten Faktor fuehrt der Login direkt auf index.php', $login['location'] === 'index.php', $failures, $passes);
|
||||
|
||||
$secret = (string)app_totp_begin_setup($pdo, $userId);
|
||||
app_totp_confirm_setup($pdo, $userId, app_totp_code($secret, intdiv(time(), APP_TOTP_PERIOD)));
|
||||
|
||||
// Ab hier ist der zweite Faktor scharf.
|
||||
$jar = [];
|
||||
$csrf = zfa_csrf("{$baseUrl}/login.php", $jar);
|
||||
$login = zfa_request("{$baseUrl}/login.php", $jar, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf, 'email' => $email, 'password' => $password,
|
||||
]));
|
||||
zfa_assert('Mit zweitem Faktor fuehrt der Login auf zwei-faktor.php', $login['location'] === 'zwei-faktor.php', $failures, $passes);
|
||||
|
||||
// Der Zwischenzustand darf sich in nichts von "gar nicht angemeldet"
|
||||
// unterscheiden - das ist der Kern der ganzen Uebung.
|
||||
$pending = zfa_request("{$baseUrl}/index.php", $jar);
|
||||
$anonymousJar = [];
|
||||
$anonymous = zfa_request("{$baseUrl}/index.php", $anonymousJar);
|
||||
zfa_assert(
|
||||
'Der Zwischenzustand liefert dasselbe wie ein unangemeldeter Aufruf',
|
||||
$pending['status'] === $anonymous['status'] && strlen($pending['body']) === strlen($anonymous['body']),
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
zfa_assert(
|
||||
'Der Zwischenzustand zeigt keine Mandantendaten',
|
||||
!str_contains($pending['body'], "2FA Check {$suffix}"),
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$csrf = zfa_csrf("{$baseUrl}/zwei-faktor.php", $jar);
|
||||
$wrong = zfa_request("{$baseUrl}/zwei-faktor.php", $jar, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf, 'code' => '000000',
|
||||
]));
|
||||
zfa_assert(
|
||||
'Falscher Code wird abgewiesen',
|
||||
$wrong['location'] === null && str_contains($wrong['body'], 'stimmt nicht'),
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
// Ein Zeitschritt weiter, weil der Einrichtungscode schon verbraucht ist.
|
||||
$csrf = zfa_csrf("{$baseUrl}/zwei-faktor.php", $jar);
|
||||
$right = zfa_request("{$baseUrl}/zwei-faktor.php", $jar, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf,
|
||||
'code' => app_totp_code($secret, intdiv(time(), APP_TOTP_PERIOD) + 1),
|
||||
]));
|
||||
zfa_assert('Richtiger Code schliesst die Anmeldung ab', $right['location'] === 'index.php', $failures, $passes);
|
||||
|
||||
$dashboard = zfa_request("{$baseUrl}/index.php", $jar);
|
||||
zfa_assert('Nach dem Code ist das Dashboard erreichbar', $dashboard['status'] === 200, $failures, $passes);
|
||||
|
||||
$strangerJar = [];
|
||||
$stranger = zfa_request("{$baseUrl}/zwei-faktor.php", $strangerJar);
|
||||
zfa_assert(
|
||||
'zwei-faktor.php ohne angefangene Anmeldung leitet zum Login',
|
||||
$stranger['location'] === 'login.php',
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
} finally {
|
||||
$pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]);
|
||||
$pdo->prepare('DELETE FROM legal_acceptances WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
|
||||
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket LIKE ?')->execute(['2fa_%']);
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
if ($failures !== []) {
|
||||
fwrite(STDERR, '2FA-HTTP-Check fehlgeschlagen: ' . implode('; ', $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "2FA-HTTP-Check bestanden mit {$passes} Zusicherungen.\n";
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/analytics.php';
|
||||
require_once __DIR__ . '/../app/legal.php';
|
||||
|
||||
$failures = 0;
|
||||
$assert = static function (string $label, bool $condition) use (&$failures): void {
|
||||
if ($condition) {
|
||||
echo "OK: {$label}\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$failures++;
|
||||
echo "FEHLER: {$label}\n";
|
||||
};
|
||||
|
||||
$environmentNames = [
|
||||
'APP_ENV',
|
||||
'APP_HOST',
|
||||
'APP_MARKETING_URL',
|
||||
'MATOMO_URL',
|
||||
'MATOMO_SITE_ID',
|
||||
'MATOMO_CONSENT_COOKIE_DOMAIN',
|
||||
'MATOMO_PRIVACY_CONFIRMED',
|
||||
];
|
||||
$previousEnvironment = [];
|
||||
foreach ($environmentNames as $name) {
|
||||
$previousEnvironment[$name] = getenv($name);
|
||||
putenv($name);
|
||||
}
|
||||
|
||||
putenv('APP_ENV=dev');
|
||||
$assert('Ohne Konfiguration ist Matomo deaktiviert', app_matomo_configuration() === null);
|
||||
$assert('Ohne Konfiguration wird kein Banner ausgegeben', app_analytics_body_html() === '');
|
||||
|
||||
putenv('MATOMO_URL=https://analytics.example.test/');
|
||||
putenv('MATOMO_SITE_ID=ungueltig');
|
||||
putenv('MATOMO_PRIVACY_CONFIRMED=1');
|
||||
$assert('Ungültige Site-ID wird abgelehnt', app_matomo_configuration_errors() !== []);
|
||||
$assert('Fehlerhafte Konfiguration lädt kein Matomo', app_matomo_configuration() === null);
|
||||
|
||||
putenv('MATOMO_SITE_ID=7');
|
||||
putenv('MATOMO_CONSENT_COOKIE_DOMAIN=.example.test');
|
||||
$assert('HTTP wird in Produktion für Matomo abgelehnt', (static function (): bool {
|
||||
putenv('APP_ENV=prod');
|
||||
putenv('MATOMO_URL=http://analytics.example.test/');
|
||||
$hasError = app_matomo_configuration_errors() !== [];
|
||||
putenv('APP_ENV=dev');
|
||||
putenv('MATOMO_URL=https://analytics.example.test/');
|
||||
return $hasError;
|
||||
})());
|
||||
$configuration = app_matomo_configuration();
|
||||
$assert('Vollständige Konfiguration wird aktiviert', $configuration !== null);
|
||||
$assert('Matomo-Basis-URL wird normalisiert', ($configuration['matomoUrl'] ?? '') === 'https://analytics.example.test/');
|
||||
$assert('Einwilligung gilt 180 Tage', ($configuration['consentMaxAge'] ?? 0) === 15552000);
|
||||
$assert('Neue Tracking-Art fragt bestehende Entscheidungen erneut ab', ($configuration['consentVersion'] ?? '') === '2');
|
||||
|
||||
$head = app_analytics_head_html();
|
||||
$body = app_analytics_body_html();
|
||||
$assert('Consent-Stylesheet wird lokal und filterarm geladen', str_contains($head, 'assets/css/privacy-settings.css'));
|
||||
$assert('Zustimmen und Ablehnen stehen in derselben Ebene',
|
||||
str_contains($body, 'data-analytics-consent="denied">Nur notwendige Cookies</button>')
|
||||
&& str_contains($body, 'data-analytics-consent="granted">Zustimmen</button>'));
|
||||
$assert('Einwilligungsdialog ist eindeutig benannt',
|
||||
str_contains($body, '<h2 id="analytics-consent-title">Cookie-Zustimmung</h2>'));
|
||||
$consentCss = file_get_contents(APP_ROOT . '/assets/css/privacy-settings.css') ?: '';
|
||||
$assert('Nur notwendige Cookies ist innen weiß gestaltet',
|
||||
str_contains($consentCss, '[data-analytics-consent="denied"]')
|
||||
&& str_contains($consentCss, 'background: #ffffff;'));
|
||||
$assert('Matomo selbst ist im HTML nicht vorab eingebunden',
|
||||
!str_contains($body, 'https://analytics.example.test')
|
||||
&& !str_contains($body, '"siteId"'));
|
||||
$assert('Kein schwebender Cookie-Schalter wird mehr ausgegeben',
|
||||
!str_contains($body, 'analytics-consent-settings'));
|
||||
$assert('Footer-Schalter wird bei aktiver Analyse ausgegeben',
|
||||
str_contains(app_analytics_footer_settings_button_html(), 'data-analytics-settings')
|
||||
&& str_contains(app_public_legal_footer(), 'Cookie-Einstellungen'));
|
||||
$assert('Browser nutzt den lokalen First-Party-Endpunkt',
|
||||
str_contains($body, '"trackingEndpoint":"nutzungsanalyse.php"')
|
||||
&& str_contains($body, 'assets/js/privacy-settings.js'));
|
||||
|
||||
$javascript = file_get_contents(APP_ROOT . '/assets/js/privacy-settings.js') ?: '';
|
||||
$assert('Tracker sendet nur bei erteilter Einwilligung',
|
||||
str_contains($javascript, "readDecision() !== 'granted'"));
|
||||
$assert('Browser sendet nur an den lokalen Endpunkt',
|
||||
str_contains($javascript, 'window.fetch(configuration.trackingEndpoint')
|
||||
&& !str_contains($javascript, 'matomo.js')
|
||||
&& !str_contains($javascript, 'matomo.php'));
|
||||
$assert('Query und Fragment werden aus Analyse-URLs entfernt',
|
||||
str_contains($javascript, 'url.origin + url.pathname'));
|
||||
$assert('Keine Kaffeeliste-Benutzerkennung wird gelesen',
|
||||
!str_contains($javascript, 'userId')
|
||||
&& !str_contains($javascript, 'sessionStorage')
|
||||
&& !str_contains($javascript, 'localStorage'));
|
||||
$assert('Widerruf stoppt Tracking und löscht Analyse-Cookies',
|
||||
str_contains($javascript, 'removeAnalysisCookies()')
|
||||
&& str_contains($javascript, "name !== visitorCookieName"));
|
||||
$assert('Alle Cookie-Einstellungs-Schalter öffnen denselben Dialog',
|
||||
str_contains($javascript, "querySelectorAll('[data-analytics-settings]')")
|
||||
&& str_contains($javascript, 'settingsButtons.forEach'));
|
||||
|
||||
$relay = file_get_contents(APP_ROOT . '/app/analytics-relay.php') ?: '';
|
||||
$assert('Relay übernimmt keine Client-IP',
|
||||
!str_contains($relay, 'REMOTE_ADDR')
|
||||
&& !str_contains($relay, "'cip'"));
|
||||
$assert('Relay bindet Ziel und Site-ID an die Serverkonfiguration',
|
||||
str_contains($relay, "['idsite'] = \$configuration['siteId']")
|
||||
&& str_contains($relay, "\$configuration['matomoUrl'] . 'matomo.php'"));
|
||||
|
||||
$publicTemplates = [
|
||||
'abo-bestellen.php',
|
||||
'agb.php',
|
||||
'avv.php',
|
||||
'datenschutz.php',
|
||||
'email-verifizieren.php',
|
||||
'impressum.php',
|
||||
'kuendigen.php',
|
||||
'landing.php',
|
||||
'login.php',
|
||||
'passwort-vergessen.php',
|
||||
'passwort-zuruecksetzen.php',
|
||||
'preise.php',
|
||||
'register.php',
|
||||
'widerruf.php',
|
||||
'widerrufen.php',
|
||||
'zwei-faktor.php',
|
||||
];
|
||||
foreach ($publicTemplates as $template) {
|
||||
$contents = file_get_contents(APP_ROOT . '/' . $template) ?: '';
|
||||
$assert("{$template} bindet die Einwilligung vollständig ein",
|
||||
str_contains($contents, 'app_analytics_head_html')
|
||||
&& str_contains($contents, 'app_analytics_body_html'));
|
||||
}
|
||||
$assert('App-Layout bindet Head und Body zentral ein',
|
||||
str_contains(file_get_contents(APP_ROOT . '/header.php') ?: '', 'app_analytics_head_html')
|
||||
&& str_contains(file_get_contents(APP_ROOT . '/footer.php') ?: '', 'app_analytics_body_html'));
|
||||
$appFooter = file_get_contents(APP_ROOT . '/footer.php') ?: '';
|
||||
$assert('App-Footer enthält Cookie-Einstellungen ohne Copyright- und Designlink',
|
||||
str_contains($appFooter, 'app_analytics_footer_settings_button_html')
|
||||
&& !str_contains($appFooter, '© Clemens Creutzburg')
|
||||
&& !str_contains($appFooter, 'html5up.net'));
|
||||
|
||||
foreach ([
|
||||
'email-verifizieren.php',
|
||||
'landing.php',
|
||||
'login.php',
|
||||
'passwort-vergessen.php',
|
||||
'passwort-zuruecksetzen.php',
|
||||
'register.php',
|
||||
'zwei-faktor.php',
|
||||
] as $manualFooterTemplate) {
|
||||
$contents = file_get_contents(APP_ROOT . '/' . $manualFooterTemplate) ?: '';
|
||||
$assert("{$manualFooterTemplate} bietet Cookie-Einstellungen im Footer an",
|
||||
str_contains($contents, 'app_analytics_footer_settings_button_html'));
|
||||
}
|
||||
|
||||
$privacy = app_legal_document_text('privacy_notice');
|
||||
$assert('Aktuelle Datenschutzerklärung beschreibt Matomo und Widerruf',
|
||||
str_contains($privacy, 'Matomo')
|
||||
&& str_contains($privacy, 'Cookie-Einstellungen')
|
||||
&& str_contains($privacy, 'kaffeeliste_analytics_visitor')
|
||||
&& str_contains($privacy, 'nicht an Matomo weitergegeben')
|
||||
&& str_contains($privacy, 'Verifizierungs-, Passwort-Reset- und Zugriffs-Tokens'));
|
||||
|
||||
foreach ($previousEnvironment as $name => $value) {
|
||||
$value === false ? putenv($name) : putenv($name . '=' . $value);
|
||||
}
|
||||
|
||||
if ($failures > 0) {
|
||||
echo "\n{$failures} Analyse-/Consent-Prüfung(en) fehlgeschlagen.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\nAnalyse und Einwilligung: alle Prüfungen bestanden.\n";
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/analytics-relay.php';
|
||||
|
||||
$failures = 0;
|
||||
$assert = static function (string $label, bool $condition) use (&$failures): void {
|
||||
if ($condition) {
|
||||
echo "OK: {$label}\n";
|
||||
return;
|
||||
}
|
||||
$failures++;
|
||||
echo "FEHLER: {$label}\n";
|
||||
};
|
||||
|
||||
$environmentNames = [
|
||||
'APP_ENV',
|
||||
'APP_HOST',
|
||||
'APP_MARKETING_URL',
|
||||
'MATOMO_URL',
|
||||
'MATOMO_SITE_ID',
|
||||
'MATOMO_CONSENT_COOKIE_DOMAIN',
|
||||
'MATOMO_PRIVACY_CONFIRMED',
|
||||
];
|
||||
$previousEnvironment = [];
|
||||
foreach ($environmentNames as $name) {
|
||||
$previousEnvironment[$name] = getenv($name);
|
||||
}
|
||||
|
||||
putenv('APP_ENV=dev');
|
||||
putenv('APP_HOST=app.example.test');
|
||||
putenv('APP_MARKETING_URL=http://example.test:8765');
|
||||
putenv('MATOMO_URL=http://analytics.example.test/');
|
||||
putenv('MATOMO_SITE_ID=7');
|
||||
putenv('MATOMO_CONSENT_COOKIE_DOMAIN=.example.test');
|
||||
putenv('MATOMO_PRIVACY_CONFIRMED=1');
|
||||
|
||||
$visitorId = '0123456789abcdef';
|
||||
$cookies = [
|
||||
APP_ANALYTICS_CONSENT_COOKIE => APP_ANALYTICS_CONSENT_VERSION . ':granted',
|
||||
APP_ANALYTICS_VISITOR_COOKIE => $visitorId,
|
||||
];
|
||||
$server = [
|
||||
'HTTP_HOST' => 'example.test:8765',
|
||||
'HTTP_ORIGIN' => 'http://example.test:8765',
|
||||
'HTTP_SEC_FETCH_SITE' => 'same-origin',
|
||||
'SERVER_PORT' => '8765',
|
||||
'HTTP_USER_AGENT' => 'Relay-Testbrowser/1.0',
|
||||
'REMOTE_ADDR' => '203.0.113.42',
|
||||
];
|
||||
$payload = [
|
||||
'visitorId' => $visitorId,
|
||||
'pageUrl' => 'http://example.test:8765/passwort-zuruecksetzen.php?token=GEHEIM#formular',
|
||||
'pageTitle' => 'Kaffeeliste Passwort zurücksetzen',
|
||||
'referrerUrl' => 'http://app.example.test/login.php?source=GEHEIM',
|
||||
'resolution' => '1440x900',
|
||||
'language' => 'de-DE',
|
||||
'h' => 12,
|
||||
'm' => 34,
|
||||
's' => 56,
|
||||
];
|
||||
|
||||
$parameters = app_matomo_page_view_parameters($payload, $server, $cookies);
|
||||
$assert('Seiten-URL verliert Query und Fragment',
|
||||
($parameters['url'] ?? '') === 'http://example.test:8765/passwort-zuruecksetzen.php');
|
||||
$assert('Interner Referrer verliert Query und Fragment',
|
||||
($parameters['urlref'] ?? '') === 'http://app.example.test/login.php');
|
||||
$assert('Zufällige Besucherkennung wird unverändert genutzt', ($parameters['_id'] ?? '') === $visitorId);
|
||||
$assert('Browserdaten werden auf erlaubte Angaben begrenzt',
|
||||
($parameters['res'] ?? '') === '1440x900'
|
||||
&& ($parameters['lang'] ?? '') === 'de-DE'
|
||||
&& ($parameters['ua'] ?? '') === 'Relay-Testbrowser/1.0');
|
||||
$assert('Client-IP wird nicht an Matomo übergeben',
|
||||
!array_key_exists('cip', $parameters)
|
||||
&& !in_array('203.0.113.42', $parameters, true));
|
||||
|
||||
$capturedUrl = '';
|
||||
$capturedParameters = [];
|
||||
$forwardResult = app_matomo_forward_page_view(
|
||||
$parameters,
|
||||
static function (string $url, array $values) use (&$capturedUrl, &$capturedParameters): array {
|
||||
$capturedUrl = $url;
|
||||
$capturedParameters = $values;
|
||||
return ['ok' => true, 'status' => 204, 'error' => null];
|
||||
}
|
||||
);
|
||||
$assert('Relay verwendet den fest konfigurierten Matomo-Endpunkt',
|
||||
$forwardResult['ok']
|
||||
&& $capturedUrl === 'http://analytics.example.test/matomo.php'
|
||||
&& ($capturedParameters['idsite'] ?? '') === '7');
|
||||
|
||||
$mustReject = static function (array $changedPayload, array $changedServer, array $changedCookies): bool {
|
||||
try {
|
||||
app_matomo_page_view_parameters($changedPayload, $changedServer, $changedCookies);
|
||||
return false;
|
||||
} catch (Throwable) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$deniedCookies = $cookies;
|
||||
$deniedCookies[APP_ANALYTICS_CONSENT_COOKIE] = APP_ANALYTICS_CONSENT_VERSION . ':denied';
|
||||
$assert('Ohne Einwilligung wird der Aufruf verworfen', $mustReject($payload, $server, $deniedCookies));
|
||||
|
||||
$foreignServer = $server;
|
||||
$foreignServer['HTTP_ORIGIN'] = 'https://evil.example';
|
||||
$foreignServer['HTTP_SEC_FETCH_SITE'] = 'cross-site';
|
||||
$assert('Fremder Ursprung wird verworfen', $mustReject($payload, $foreignServer, $cookies));
|
||||
|
||||
$foreignPage = $payload;
|
||||
$foreignPage['pageUrl'] = 'https://evil.example/gestohlen?token=1';
|
||||
$assert('Fremde Seiten-URL wird verworfen', $mustReject($foreignPage, $server, $cookies));
|
||||
|
||||
$wrongVisitor = $payload;
|
||||
$wrongVisitor['visitorId'] = 'fedcba9876543210';
|
||||
$assert('Nicht zum Cookie passende Besucherkennung wird verworfen', $mustReject($wrongVisitor, $server, $cookies));
|
||||
|
||||
$relaySource = file_get_contents(APP_ROOT . '/app/analytics-relay.php') ?: '';
|
||||
$assert('Relay-Code liest die Client-IP nicht', !str_contains($relaySource, "['REMOTE_ADDR']"));
|
||||
|
||||
foreach ($previousEnvironment as $name => $value) {
|
||||
$value === false ? putenv($name) : putenv($name . '=' . $value);
|
||||
}
|
||||
|
||||
if ($failures > 0) {
|
||||
echo "\n{$failures} Relay-Prüfung(en) fehlgeschlagen.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\nFirst-Party-Matomo-Relay: alle Prüfungen bestanden.\n";
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/prefixed-pdo.php';
|
||||
|
||||
$assertSame = static function (string $expected, string $actual, string $message): void {
|
||||
if ($expected !== $actual) {
|
||||
fwrite(STDERR, "FEHLER: {$message}\nErwartet: {$expected}\nTatsächlich: {$actual}\n");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
$assertSame('', app_validate_db_table_prefix(''), 'Ein leerer Prefix muss für die einmalige Datenübernahme erlaubt sein.');
|
||||
$assertSame('test_', app_validate_db_table_prefix('test_'), 'Der Test-Prefix muss gültig sein.');
|
||||
$assertSame('prod_', app_validate_db_table_prefix('prod_'), 'Der Produktions-Prefix muss gültig sein.');
|
||||
|
||||
foreach (['Test_', '1test_', 'test', 'test-'] as $invalidPrefix) {
|
||||
try {
|
||||
app_validate_db_table_prefix($invalidPrefix);
|
||||
fwrite(STDERR, "FEHLER: Ungültiger Prefix {$invalidPrefix} wurde akzeptiert.\n");
|
||||
exit(1);
|
||||
} catch (RuntimeException) {
|
||||
// Erwartet.
|
||||
}
|
||||
}
|
||||
|
||||
$previousAppEnv = getenv('APP_ENV');
|
||||
$previousDbPrefix = getenv('DB_TABLE_PREFIX');
|
||||
putenv('APP_ENV=prod');
|
||||
putenv('DB_TABLE_PREFIX');
|
||||
try {
|
||||
app_db_table_prefix();
|
||||
fwrite(STDERR, "FEHLER: Produktion wurde ohne DB_TABLE_PREFIX akzeptiert.\n");
|
||||
exit(1);
|
||||
} catch (RuntimeException) {
|
||||
// Erwartet: Außerhalb von Dev gilt fail-closed.
|
||||
} finally {
|
||||
$previousAppEnv === false ? putenv('APP_ENV') : putenv('APP_ENV=' . $previousAppEnv);
|
||||
$previousDbPrefix === false ? putenv('DB_TABLE_PREFIX') : putenv('DB_TABLE_PREFIX=' . $previousDbPrefix);
|
||||
}
|
||||
|
||||
$assertSame(
|
||||
'SELECT * FROM test_users JOIN test_tenants ON test_tenants.id = test_users.tenant_id',
|
||||
app_prefix_database_sql('SELECT * FROM users JOIN tenants ON tenants.id = users.tenant_id', 'test_'),
|
||||
'Normale Laufzeit-Abfragen müssen vollständig umgeschrieben werden.'
|
||||
);
|
||||
$assertSame(
|
||||
'SELECT * FROM test_users',
|
||||
app_prefix_database_sql('SELECT * FROM test_users', 'test_'),
|
||||
'Bereits umgeschriebene Tabellennamen dürfen keinen doppelten Prefix erhalten.'
|
||||
);
|
||||
$assertSame(
|
||||
"ALTER TABLE test_users ADD CONSTRAINT test_fk_users_tenant FOREIGN KEY (tenant_id) REFERENCES test_tenants(id)",
|
||||
app_prefix_database_sql(
|
||||
'ALTER TABLE users ADD CONSTRAINT fk_users_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)',
|
||||
'test_'
|
||||
),
|
||||
'Tabellen und Fremdschlüssel müssen gemeinsam umgeschrieben werden.'
|
||||
);
|
||||
$assertSame(
|
||||
"WHERE TABLE_NAME = 'test_users' AND CONSTRAINT_NAME = 'test_fk_users_tenant'",
|
||||
app_prefix_database_sql("WHERE TABLE_NAME = 'users' AND CONSTRAINT_NAME = 'fk_users_tenant'", 'test_'),
|
||||
'Dynamische Migrationen müssen auch INFORMATION_SCHEMA-Vergleiche umschreiben.'
|
||||
);
|
||||
|
||||
$migrationFiles = glob(__DIR__ . '/../database/migrations/*.sql') ?: [];
|
||||
if (count($migrationFiles) !== 32) {
|
||||
fwrite(STDERR, 'FEHLER: Erwartet wurden 32 Migrationen, gefunden wurden ' . count($migrationFiles) . ".\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$allMigrationSql = '';
|
||||
foreach ($migrationFiles as $migrationFile) {
|
||||
$contents = file_get_contents($migrationFile);
|
||||
if ($contents === false) {
|
||||
fwrite(STDERR, "FEHLER: Migration {$migrationFile} konnte nicht gelesen werden.\n");
|
||||
exit(1);
|
||||
}
|
||||
$allMigrationSql .= "\n" . $contents;
|
||||
}
|
||||
$prefixedMigrationSql = app_prefix_database_sql($allMigrationSql, 'test_');
|
||||
|
||||
foreach (app_database_table_names() as $table) {
|
||||
$rawPattern = '/(?<![A-Za-z0-9_])' . preg_quote($table, '/') . '(?![A-Za-z0-9_])/i';
|
||||
if (preg_match($rawPattern, $allMigrationSql) === 1 && preg_match($rawPattern, $prefixedMigrationSql) === 1) {
|
||||
fwrite(STDERR, "FEHLER: Tabelle {$table} blieb in den umgeschriebenen Migrationen ohne Prefix.\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
if (preg_match('/(?<![A-Za-z0-9_])fk_[A-Za-z0-9_]+(?![A-Za-z0-9_])/i', $prefixedMigrationSql) === 1) {
|
||||
fwrite(STDERR, "FEHLER: Mindestens ein Fremdschlüssel blieb in den Migrationen ohne Prefix.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "DB-Prefix-Prüfung bestanden: Produktion ist fail-closed; Laufzeit-SQL und alle 32 Migrationen werden getrennt umgeschrieben.\n";
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Diagnose fuer den PayPal-Mailabruf: klaert in einem Lauf, ob dieser Host
|
||||
* das Postfach ueberhaupt erreichen kann - und auf welchem Weg.
|
||||
*
|
||||
* Geprueft wird:
|
||||
* 1. PHP-Version und ob die imap-Erweiterung geladen ist (seit PHP 8.4 ist
|
||||
* sie nicht mehr Teil von PHP, sondern nur noch ueber PECL zu haben).
|
||||
* 2. Ob die PAYPAL_*-Einstellungen in der env.local.php gesetzt sind.
|
||||
* 3. Ob sich das Postfach ohne die Erweiterung erreichen laesst - ueber
|
||||
* eine reine TLS-Verbindung, wie sie auch ein Fallback nutzen wuerde.
|
||||
*
|
||||
* Der Test ist bewusst nur lesend: EXAMINE statt SELECT, kein Flag wird
|
||||
* gesetzt, keine Mail veraendert. Das Passwort wird nie ausgegeben.
|
||||
*
|
||||
* Aufruf (Plesk: Geplante Aufgabe vom Typ "PHP-Skript"):
|
||||
* /httpdocs/scripts/check-imap-support.php
|
||||
*/
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit("Dieses Skript ist nur fuer die Kommandozeile gedacht.\n");
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../app/bootstrap.php';
|
||||
// paypal_from_is_paypal() - in der Auflistung nur als Information; die
|
||||
// Verarbeitung selbst prueft den Absender nicht.
|
||||
require_once __DIR__ . '/../app/paypal-inbox.php';
|
||||
|
||||
function imap_check_line(string $label, string $value): void
|
||||
{
|
||||
printf("%-28s %s\n", $label . ':', $value);
|
||||
}
|
||||
|
||||
echo "=== PHP ===\n";
|
||||
imap_check_line('Version', PHP_VERSION);
|
||||
imap_check_line('imap-Erweiterung', function_exists('imap_open') ? 'geladen' : 'FEHLT');
|
||||
imap_check_line('openssl', extension_loaded('openssl') ? 'vorhanden' : 'FEHLT');
|
||||
imap_check_line('Sockets/Streams', function_exists('stream_socket_client') ? 'vorhanden' : 'FEHLT');
|
||||
if (!function_exists('imap_open') && version_compare(PHP_VERSION, '8.4', '>=')) {
|
||||
echo "Hinweis: Ab PHP 8.4 ist imap kein Bestandteil von PHP mehr. Entweder den\n"
|
||||
. "PHP-Handler dieses Vhosts auf 8.3 stellen oder den Abruf ueber eine reine\n"
|
||||
. "TLS-Verbindung laufen lassen (siehe Test unten).\n";
|
||||
}
|
||||
|
||||
echo "\n=== Einstellungen (env.local.php) ===\n";
|
||||
$host = app_env('PAYPAL_IMAP_HOST');
|
||||
$user = app_env('PAYPAL_IMAP_USER');
|
||||
$pass = app_env('PAYPAL_IMAP_PASS');
|
||||
$port = (int) app_env('PAYPAL_IMAP_PORT', '993');
|
||||
$flags = (string) app_env('PAYPAL_IMAP_FLAGS', '/imap/ssl');
|
||||
$mailbox = (string) app_env('PAYPAL_IMAP_MAILBOX', 'INBOX');
|
||||
|
||||
imap_check_line('PAYPAL_INBOX_BASE', (string) (app_env('PAYPAL_INBOX_BASE') ?? '(nicht gesetzt)'));
|
||||
imap_check_line('PAYPAL_IMAP_HOST', (string) ($host ?? '(nicht gesetzt)'));
|
||||
imap_check_line('PAYPAL_IMAP_PORT', (string) $port);
|
||||
imap_check_line('PAYPAL_IMAP_FLAGS', $flags);
|
||||
imap_check_line('PAYPAL_IMAP_USER', (string) ($user ?? '(nicht gesetzt)'));
|
||||
// Das Passwort selbst gehoert nicht in ein Protokoll, das per Mail
|
||||
// verschickt oder weitergereicht wird - nur die Laenge als Tippfehler-Probe.
|
||||
imap_check_line('PAYPAL_IMAP_PASS', $pass === null ? '(nicht gesetzt)' : 'gesetzt (' . strlen($pass) . ' Zeichen)');
|
||||
imap_check_line('PAYPAL_IMAP_MAILBOX', $mailbox);
|
||||
|
||||
if ($host === null || $user === null || $pass === null) {
|
||||
echo "\nOhne Host, Benutzer und Passwort ist kein Verbindungstest moeglich.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Antwort bis zur Zeile mit dem gesuchten Tag.
|
||||
*
|
||||
* @return array{ok: bool, lines: list<string>}
|
||||
*/
|
||||
function imap_check_read($sock, string $tag): array
|
||||
{
|
||||
$lines = [];
|
||||
while (($line = fgets($sock)) !== false) {
|
||||
$line = rtrim($line, "\r\n");
|
||||
$lines[] = $line;
|
||||
if (str_starts_with($line, $tag . ' ')) {
|
||||
return ['ok' => str_starts_with($line, $tag . ' OK'), 'lines' => $lines];
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => false, 'lines' => $lines];
|
||||
}
|
||||
|
||||
function imap_check_send($sock, string $tag, string $command): array
|
||||
{
|
||||
fwrite($sock, $tag . ' ' . $command . "\r\n");
|
||||
|
||||
return imap_check_read($sock, $tag);
|
||||
}
|
||||
|
||||
echo "\n=== Verbindungstest ohne imap-Erweiterung (nur lesend) ===\n";
|
||||
|
||||
// Port 993 = TLS von Anfang an; alles andere (typisch 143) = STARTTLS.
|
||||
$implizitesTls = $port === 993 || str_contains($flags, '/ssl');
|
||||
$adresse = ($implizitesTls ? 'ssl://' : 'tcp://') . $host . ':' . $port;
|
||||
imap_check_line('Verbindung', $adresse . ($implizitesTls ? ' (TLS sofort)' : ' (STARTTLS)'));
|
||||
|
||||
$fehlerNr = 0;
|
||||
$fehlerText = '';
|
||||
$sock = @stream_socket_client($adresse, $fehlerNr, $fehlerText, 15);
|
||||
if ($sock === false) {
|
||||
echo "FEHLER: Verbindung nicht moeglich ({$fehlerNr}: {$fehlerText})\n";
|
||||
echo "Moegliche Ursachen: Host/Port falsch, ausgehende Verbindungen gesperrt,\n"
|
||||
. "oder TLS-Zertifikat nicht akzeptiert.\n";
|
||||
exit(1);
|
||||
}
|
||||
stream_set_timeout($sock, 15);
|
||||
|
||||
$greeting = rtrim((string) fgets($sock), "\r\n");
|
||||
imap_check_line('Begruessung', $greeting !== '' ? $greeting : '(keine)');
|
||||
|
||||
if (!$implizitesTls) {
|
||||
$start = imap_check_send($sock, 'a0', 'STARTTLS');
|
||||
if (!$start['ok'] || !@stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
echo "FEHLER: STARTTLS fehlgeschlagen - ohne Verschluesselung wird nicht angemeldet.\n";
|
||||
exit(1);
|
||||
}
|
||||
imap_check_line('STARTTLS', 'ok');
|
||||
}
|
||||
|
||||
// Anfuehrungszeichen und Backslashes im Passwort maskieren (IMAP-Quoting).
|
||||
$quote = static fn(string $wert): string => '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $wert) . '"';
|
||||
|
||||
$login = imap_check_send($sock, 'a1', 'LOGIN ' . $quote($user) . ' ' . $quote($pass));
|
||||
if (!$login['ok']) {
|
||||
echo "FEHLER: Anmeldung abgelehnt.\n";
|
||||
echo ' Antwort: ' . end($login['lines']) . "\n";
|
||||
fwrite($sock, "a9 LOGOUT\r\n");
|
||||
fclose($sock);
|
||||
exit(1);
|
||||
}
|
||||
imap_check_line('Anmeldung', 'ok');
|
||||
|
||||
// EXAMINE statt SELECT: oeffnet das Postfach ausdruecklich nur lesend.
|
||||
$examine = imap_check_send($sock, 'a2', 'EXAMINE ' . $quote($mailbox));
|
||||
if (!$examine['ok']) {
|
||||
echo "FEHLER: Postfach '{$mailbox}' nicht zu oeffnen.\n";
|
||||
echo ' Antwort: ' . end($examine['lines']) . "\n";
|
||||
fwrite($sock, "a9 LOGOUT\r\n");
|
||||
fclose($sock);
|
||||
exit(1);
|
||||
}
|
||||
$gesamt = 0;
|
||||
foreach ($examine['lines'] as $line) {
|
||||
if (preg_match('/^\* (\d+) EXISTS/', $line, $m)) {
|
||||
$gesamt = (int) $m[1];
|
||||
}
|
||||
}
|
||||
imap_check_line('Postfach', $mailbox . ' geoeffnet');
|
||||
imap_check_line('Mails gesamt', (string) $gesamt);
|
||||
|
||||
$search = imap_check_send($sock, 'a3', 'SEARCH UNSEEN');
|
||||
$ungelesen = 0;
|
||||
foreach ($search['lines'] as $line) {
|
||||
if (str_starts_with($line, '* SEARCH')) {
|
||||
$ids = trim(substr($line, strlen('* SEARCH')));
|
||||
$ungelesen = $ids === '' ? 0 : count(preg_split('/\s+/', $ids) ?: []);
|
||||
}
|
||||
}
|
||||
imap_check_line('davon ungelesen', (string) $ungelesen);
|
||||
|
||||
fwrite($sock, "a9 LOGOUT\r\n");
|
||||
fclose($sock);
|
||||
|
||||
// Mit der Erweiterung laesst sich zusaetzlich auflisten, was im Postfach
|
||||
// liegt - die haeufigste Frage bei einer nicht verarbeiteten Mail ist "wer
|
||||
// war der Absender und an welche Adresse ging sie?". Rein lesend:
|
||||
// imap_fetch_overview() und imap_fetchheader() setzen kein \Seen.
|
||||
if (function_exists('imap_open')) {
|
||||
echo "\n=== Postfachinhalt (nur lesend, letzte 15) ===\n";
|
||||
$mbox = @imap_open('{' . $host . ':' . $port . $flags . '}' . $mailbox, $user, $pass, OP_READONLY);
|
||||
if ($mbox === false) {
|
||||
echo 'Zugriff ueber die imap-Erweiterung fehlgeschlagen: ' . imap_last_error() . "\n";
|
||||
} else {
|
||||
$anzahl = imap_num_msg($mbox);
|
||||
$von = max(1, $anzahl - 14);
|
||||
for ($msgno = $von; $msgno <= $anzahl; $msgno++) {
|
||||
$overview = imap_fetch_overview($mbox, (string) $msgno, 0)[0] ?? null;
|
||||
if ($overview === null) {
|
||||
continue;
|
||||
}
|
||||
$kopf = imap_fetchheader($mbox, $msgno);
|
||||
$empfaenger = [];
|
||||
if (preg_match_all('/^(?:Delivered-To|X-Original-To|To|X-Forwarded-To):\s*(.+)$/im', $kopf, $alle)) {
|
||||
$empfaenger = array_map('trim', $alle[1]);
|
||||
}
|
||||
$from = (string) ($overview->from ?? '');
|
||||
printf(
|
||||
"#%-3d %s Von: %s\n An: %s\n Betreff: %s\n Absender sieht nach PayPal aus: %s\n",
|
||||
$msgno,
|
||||
(int) ($overview->seen ?? 0) === 1 ? 'gelesen ' : 'UNGELESEN',
|
||||
$from,
|
||||
implode(' | ', $empfaenger) !== '' ? implode(' | ', $empfaenger) : '(keine Empfaengerzeile gefunden)',
|
||||
(string) ($overview->subject ?? ''),
|
||||
paypal_from_is_paypal($from) ? 'ja' : 'nein (wird trotzdem verarbeitet)'
|
||||
);
|
||||
}
|
||||
imap_close($mbox);
|
||||
}
|
||||
}
|
||||
|
||||
echo "\nErgebnis: Postfach erreichbar und Zugangsdaten gueltig.\n";
|
||||
if (function_exists('imap_open')) {
|
||||
echo "Die imap-Erweiterung ist vorhanden - scripts/fetch-paypal-payments.php\n"
|
||||
. "laeuft ohne weitere Aenderung. Naechster Schritt: derselbe Task mit --dry-run.\n";
|
||||
} else {
|
||||
echo "Die imap-Erweiterung fehlt, das Postfach ist aber ueber die reine\n"
|
||||
. "TLS-Verbindung erreichbar - ein Abruf ohne Erweiterung ist also moeglich.\n";
|
||||
}
|
||||
@@ -169,13 +169,21 @@ try {
|
||||
// ---------------------------------------------------------------
|
||||
$cookies = [];
|
||||
$csrf = konto_csrf("{$baseUrl}/login.php", $cookies);
|
||||
// Das Login-Formular kennt kein Kundenkuerzel mehr: wer bei mehreren
|
||||
// Mandanten Mitglied ist, waehlt nach dem Passwort per Klarnamen aus.
|
||||
$login = konto_request("{$baseUrl}/login.php", $cookies, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf,
|
||||
'email' => "kontocheck-{$suffix}@test.local",
|
||||
'password' => $altesPasswort,
|
||||
'tenant_slug' => "kontocheck-a-{$suffix}",
|
||||
]));
|
||||
pruefe('Login leitet auf index.php statt konto.php', $login['location'] === 'index.php');
|
||||
pruefe('Login mit mehreren Mandanten fuehrt zur Mandantenauswahl', $login['location'] === 'mandant-auswahl.php');
|
||||
|
||||
$csrf = konto_csrf("{$baseUrl}/mandant-auswahl.php", $cookies);
|
||||
$ersteWahl = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf,
|
||||
'tenant_id' => (string)$tenantAId,
|
||||
]));
|
||||
pruefe('Mandantenwahl nach dem Login leitet auf index.php statt konto.php', $ersteWahl['location'] === 'index.php');
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 3. Verifikation wird fuer Einladungen erzwungen
|
||||
@@ -281,7 +289,6 @@ try {
|
||||
'csrf_token' => $csrf,
|
||||
'email' => "kontocheck-{$suffix}@test.local",
|
||||
'password' => $altesPasswort,
|
||||
'tenant_slug' => "kontocheck-a-{$suffix}",
|
||||
]));
|
||||
pruefe('Login mit altem Passwort schlaegt fehl', $altLogin['location'] === null);
|
||||
|
||||
@@ -291,9 +298,8 @@ try {
|
||||
'csrf_token' => $csrf,
|
||||
'email' => "kontocheck-{$suffix}@test.local",
|
||||
'password' => $neuesPasswort,
|
||||
'tenant_slug' => "kontocheck-a-{$suffix}",
|
||||
]));
|
||||
pruefe('Login mit neuem Passwort funktioniert', $neuLogin['location'] === 'index.php');
|
||||
pruefe('Login mit neuem Passwort funktioniert', $neuLogin['location'] === 'mandant-auswahl.php');
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Reset-Link bestaetigt die Adresse mit
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/dev-db.php';
|
||||
require_once __DIR__ . '/../app/saas-auth.php';
|
||||
require_once __DIR__ . '/../app/legal-requests.php';
|
||||
require_once __DIR__ . '/../app/data-export.php';
|
||||
|
||||
$pdo = dev_pdo();
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
$slug = 'legal-check-' . $suffix;
|
||||
$email = $slug . '@test.local';
|
||||
$failures = [];
|
||||
$passes = 0;
|
||||
$assert = static function (string $label, bool $condition) use (&$failures, &$passes): void {
|
||||
if ($condition) {
|
||||
$passes++;
|
||||
echo "PASS {$label}\n";
|
||||
} else {
|
||||
$failures[] = $label;
|
||||
echo "FAIL {$label}\n";
|
||||
}
|
||||
};
|
||||
|
||||
$missingAcceptance = saas_register_tenant_owner($pdo, [
|
||||
'tenant_name' => 'Legal Check Invalid',
|
||||
'tenant_slug' => $slug . '-invalid',
|
||||
'display_name' => 'Legal Check',
|
||||
'email' => 'invalid-' . $email,
|
||||
'password' => 'Legal-check-123!',
|
||||
'password_confirm' => 'Legal-check-123!',
|
||||
'customer_type' => 'consumer',
|
||||
]);
|
||||
$assert('Registrierung ohne Rechtstextannahme wird abgelehnt', $missingAcceptance['ok'] === false);
|
||||
|
||||
$registration = saas_register_tenant_owner($pdo, [
|
||||
'tenant_name' => 'Legal Compliance Check',
|
||||
'tenant_slug' => $slug,
|
||||
'display_name' => 'Legal Check',
|
||||
'email' => $email,
|
||||
'password' => 'Legal-check-123!',
|
||||
'password_confirm' => 'Legal-check-123!',
|
||||
'customer_type' => 'consumer',
|
||||
'accept_terms' => true,
|
||||
'acknowledge_privacy' => true,
|
||||
'accept_dpa' => true,
|
||||
]);
|
||||
$assert('B2C-Registrierung gelingt mit Pflichtbestätigungen', $registration['ok'] === true);
|
||||
|
||||
$tenantId = (int)($registration['identity']['tenant_id'] ?? 0);
|
||||
$userId = (int)($registration['identity']['user_id'] ?? 0);
|
||||
if ($tenantId > 0) {
|
||||
$stmt = $pdo->prepare('SELECT customer_type FROM tenants WHERE id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$assert('Vertragstyp Verbraucher wird gespeichert', $stmt->fetchColumn() === 'consumer');
|
||||
|
||||
$stmt = $pdo->prepare('SELECT document_type, document_version, metadata_json FROM legal_acceptances WHERE tenant_id = ? ORDER BY document_type');
|
||||
$stmt->execute([$tenantId]);
|
||||
$acceptances = $stmt->fetchAll();
|
||||
$types = array_column($acceptances, 'document_type');
|
||||
$assert('AGB, AVV und Datenschutzhinweis werden versioniert protokolliert', $types === ['dpa', 'privacy_notice', 'terms']);
|
||||
$acceptanceEvidence = json_decode((string)$acceptances[0]['metadata_json'], true);
|
||||
$assert('Rechtstext-Hash ist im Nachweis enthalten', !empty($acceptanceEvidence['document_sha256']));
|
||||
$assert('Vertragsreferenz bleibt im Nachweis auch nach Kontolöschung erhalten', ($acceptanceEvidence['tenant_slug'] ?? null) === $slug && ($acceptanceEvidence['user_email'] ?? null) === $email);
|
||||
|
||||
$input = [
|
||||
'request_type' => 'cancellation',
|
||||
'requester_name' => 'Legal Check',
|
||||
'requester_email' => $email,
|
||||
'contract_reference' => $slug,
|
||||
'cancellation_kind' => 'ordinary',
|
||||
'requested_end' => 'earliest',
|
||||
'request_reason' => '',
|
||||
'metadata' => ['cancellation_kind' => 'ordinary'],
|
||||
];
|
||||
$stored = app_store_legal_request($pdo, $input);
|
||||
$processing = app_process_cancellation_request($pdo, $stored, $input);
|
||||
$assert('Kündigung wird eindeutig dem Vertrag zugeordnet', $stored['tenant_id'] === $tenantId);
|
||||
$assert('Kostenloser Vertrag wird zum bestätigten Zeitpunkt beendet', $processing['status'] === 'contract_ended');
|
||||
$confirmation = app_fetch_legal_request_by_token($pdo, $stored['access_token']);
|
||||
$assert('Dauerhafte Kündigungsbestätigung ist per Geheimtoken abrufbar', $confirmation !== null && $confirmation['reference_code'] === $stored['reference_code']);
|
||||
$_GET = ['token' => $stored['access_token']];
|
||||
ob_start();
|
||||
include __DIR__ . '/../rechtserklaerung-bestaetigung.php';
|
||||
$confirmationText = (string)ob_get_clean();
|
||||
$_GET = [];
|
||||
$assert('Bestätigungsdownload lässt sich mit einem gültigen Token rendern', str_contains($confirmationText, $stored['reference_code']));
|
||||
|
||||
$export = app_export_tenant_data($pdo, $tenantId);
|
||||
$assert('Datenexport enthält Legal-Nachweise', count($export['legal_acceptances']) === 3 && count($export['legal_requests']) === 1);
|
||||
|
||||
$pdo->prepare('DELETE FROM legal_requests WHERE reference_code = ?')->execute([$stored['reference_code']]);
|
||||
$pdo->prepare('DELETE FROM legal_acceptances WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
|
||||
}
|
||||
|
||||
$withdrawalSlug = $slug . '-withdrawal';
|
||||
$withdrawalEmail = 'withdrawal-' . $email;
|
||||
$withdrawalRegistration = saas_register_tenant_owner($pdo, [
|
||||
'tenant_name' => 'Legal Withdrawal Check',
|
||||
'tenant_slug' => $withdrawalSlug,
|
||||
'display_name' => 'Withdrawal Check',
|
||||
'email' => $withdrawalEmail,
|
||||
'password' => 'Legal-check-123!',
|
||||
'password_confirm' => 'Legal-check-123!',
|
||||
'customer_type' => 'consumer',
|
||||
'accept_terms' => true,
|
||||
'acknowledge_privacy' => true,
|
||||
'accept_dpa' => true,
|
||||
]);
|
||||
if ($withdrawalRegistration['ok']) {
|
||||
$withdrawalTenantId = (int)$withdrawalRegistration['identity']['tenant_id'];
|
||||
$withdrawalUserId = (int)$withdrawalRegistration['identity']['user_id'];
|
||||
$withdrawalInput = [
|
||||
'request_type' => 'withdrawal',
|
||||
'requester_name' => 'Withdrawal Check',
|
||||
'requester_email' => $withdrawalEmail,
|
||||
'contract_reference' => $withdrawalSlug,
|
||||
'request_reason' => '',
|
||||
];
|
||||
$withdrawalStored = app_store_legal_request($pdo, $withdrawalInput);
|
||||
$withdrawalProcessing = app_process_withdrawal_request($pdo, $withdrawalStored);
|
||||
$assert('B2C-Widerruf innerhalb von 14 Tagen beendet den kostenlosen Vertrag', $withdrawalProcessing['status'] === 'withdrawal_effective');
|
||||
$stmt = $pdo->prepare('SELECT contract_ends_at FROM tenants WHERE id = ?');
|
||||
$stmt->execute([$withdrawalTenantId]);
|
||||
$assert('Widerruf sperrt den Vertrag technisch', $stmt->fetchColumn() !== null);
|
||||
|
||||
$pdo->prepare('DELETE FROM legal_requests WHERE reference_code = ?')->execute([$withdrawalStored['reference_code']]);
|
||||
$pdo->prepare('DELETE FROM legal_acceptances WHERE tenant_id = ?')->execute([$withdrawalTenantId]);
|
||||
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$withdrawalTenantId]);
|
||||
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$withdrawalUserId]);
|
||||
} else {
|
||||
$assert('B2C-Widerrufstest konnte registriert werden', false);
|
||||
}
|
||||
|
||||
$css = file_get_contents(__DIR__ . '/../assets/css/main.css') ?: '';
|
||||
$assert('Kein dynamischer Google-Fonts-Abruf im Haupt-CSS', !str_contains($css, 'fonts.googleapis.com'));
|
||||
$assert('Widerrufsfunktion ist hervorgehoben verlinkt', str_contains(app_public_legal_footer(), 'Vertrag widerrufen'));
|
||||
$assert('Kündigungsschaltfläche ist ständig verlinkt', str_contains(app_public_legal_footer(), 'Verträge hier kündigen'));
|
||||
$assert('Bestellschaltfläche weist eindeutig auf die Zahlungspflicht hin', str_contains((string)file_get_contents(__DIR__ . '/../abo-bestellen.php'), 'zahlungspflichtig bestellen'));
|
||||
$stripeCode = (string)file_get_contents(__DIR__ . '/../app/stripe.php');
|
||||
$assert('Stripe-Portal ist auf das Ändern des Zahlungsmittels beschränkt', str_contains($stripeCode, "'type' => 'payment_method_update'"));
|
||||
|
||||
if ($failures !== []) {
|
||||
echo "\nLegal compliance flow failed with " . count($failures) . " failure(s):\n";
|
||||
foreach ($failures as $failure) {
|
||||
echo "- {$failure}\n";
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\nLegal compliance flow passed with {$passes} assertions.\n";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user