Rechtstexte und B2C-Vertragsabläufe absichern

This commit is contained in:
2026-08-22 14:31:56 +02:00
parent d320a4fd7a
commit a31a235422
58 changed files with 2502 additions and 316 deletions
+8 -1
View File
@@ -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.
+106
View File
@@ -0,0 +1,106 @@
<?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">
</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 (Stand <?php echo saas_html(APP_TERMS_VERSION); ?>)</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 (Stand <?php echo saas_html(APP_WITHDRAWAL_VERSION); ?>)</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(); ?>
<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>
+51 -8
View File
@@ -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 {
}
}
billing_update($pdo, $tenantId, [
'plan_code' => 'free',
'subscription_status' => 'canceled',
if ($billing['stripe_subscription_id'] === null) {
billing_update($pdo, $tenantId, [
'plan_code' => 'free',
'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;
}
+12 -110
View File
@@ -1,127 +1,29 @@
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
<?php
require_once __DIR__ . '/app/legal.php';
?>
<!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">
</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">&larr; 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(); ?>
<script src="assets/js/jquery.min.js"></script>
<script src="assets/js/browser.min.js"></script>
<script src="assets/js/breakpoints.min.js"></script>
+1
View File
@@ -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 (?, ?, ?, ?, ?, ?, ?)'
+54 -1
View File
@@ -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,
];
}
+7 -1
View File
@@ -110,6 +110,12 @@ 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);
}
@@ -141,7 +147,7 @@ function app_features_update(PDO $pdo, int $tenantId, array $submittedFeatures):
);
foreach (array_keys(app_feature_catalog()) as $key) {
$enabled = !empty($submittedFeatures[$key]);
$enabled = $key === 'data_export' || !empty($submittedFeatures[$key]);
$stmt->execute([$tenantId, $key, $enabled ? 1 : 0]);
if (($before[$key] ?? true) !== $enabled) {
$changed[] = $key;
+301
View File
@@ -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),
];
}
+254
View File
@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
const APP_TERMS_VERSION = '2026-08-22';
const APP_PRIVACY_VERSION = '2026-08-22';
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>'
. '</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;
}
+3 -4
View File
@@ -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)'
+42 -8
View File
@@ -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,
@@ -312,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'],
@@ -344,6 +351,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
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')));
@@ -370,6 +378,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
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.';
}
@@ -442,10 +453,10 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
$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
@@ -554,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
@@ -593,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
@@ -630,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']);
@@ -1021,7 +1035,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
@@ -1206,6 +1221,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) {
@@ -1226,6 +1242,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];
@@ -1265,10 +1293,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(
@@ -1311,6 +1339,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();
@@ -1335,6 +1368,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'],
+67
View File
@@ -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.
+44 -6
View File
@@ -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
*/
-1
View File
@@ -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
+8
View File
@@ -343,6 +343,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;
}
+33
View File
@@ -0,0 +1,33 @@
<?php
require_once __DIR__ . '/app/legal.php';
?>
<!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">
</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">&larr; Zurück zur Startseite</a></p>
</section>
</div>
<?php echo app_public_legal_footer(); ?>
<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>
+36
View File
@@ -9,6 +9,16 @@ $user = app_require_platform_admin($pdo);
$tenants = app_backoffice_fetch_tenants($pdo);
$billingPlans = billing_plans();
$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 +31,31 @@ 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>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 +79,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>
@@ -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);
+4 -3
View File
@@ -44,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(); ?>
+12 -108
View File
@@ -1,125 +1,29 @@
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
<?php
require_once __DIR__ . '/app/legal.php';
?>
<!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">
</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">&larr; 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(); ?>
<script src="assets/js/jquery.min.js"></script>
<script src="assets/js/browser.min.js"></script>
<script src="assets/js/breakpoints.min.js"></script>
+24 -15
View File
@@ -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
+44 -6
View File
@@ -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
@@ -54,6 +54,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.
@@ -135,7 +137,7 @@ 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 30 versionierte Migrationen in `database/migrations/`, angewendet
über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur
fehlende Migrationen an.
@@ -173,6 +175,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 1530 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 +185,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 +233,12 @@ 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.
## PayPal-Postfach
Für die automatische Verbuchung weitergeleiteter PayPal-Zahlungsmails
@@ -299,6 +311,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)
@@ -308,8 +330,24 @@ 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
- [ ] `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
- [ ] 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
- [ ] Frische Produktivdatenbank ohne Testdaten
## Bewusst offen
+4
View File
@@ -62,7 +62,11 @@ $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>
</footer>
<script src="assets/js/jquery.min.js"></script>
+7
View File
@@ -23,6 +23,7 @@ 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
@@ -44,6 +45,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=');
+1 -1
View File
@@ -133,7 +133,7 @@ if ($saasNavUser !== null) {
<!-- Footer -->
<footer id="footer">
<p class="copyright">&copy; 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">&copy; Clemens Creutzburg · Design: <a href="https://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="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></p>
</footer>
+15 -9
View File
@@ -1,4 +1,4 @@
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
<?php require_once __DIR__ . '/app/legal.php'; ?>
<!DOCTYPE HTML>
<html lang="de">
<head>
@@ -32,12 +32,23 @@
<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>Aufgrund der Kleinunternehmerregelung gemäß § 19 UStG sind die Umsätze von der Umsatzsteuer befreit.</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
deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden
@@ -49,12 +60,7 @@
</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(); ?>
<script src="assets/js/jquery.min.js"></script>
<script src="assets/js/browser.min.js"></script>
+13
View File
@@ -5,6 +5,9 @@ require_once __DIR__ . '/app/features.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']);
@@ -45,6 +48,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>
+154
View File
@@ -0,0 +1,154 @@
<?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">
</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']); ?>&amp;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(); ?>
<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>
+4
View File
@@ -283,7 +283,11 @@ $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>
</footer>
<script src="assets/js/jquery.min.js"></script>
+89
View File
@@ -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
+84
View File
@@ -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
+89
View File
@@ -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
+32
View File
@@ -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
+4
View File
@@ -115,7 +115,11 @@ 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>
</footer>
<script src="assets/js/jquery.min.js"></script>
+21 -5
View File
@@ -67,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',
@@ -137,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>
@@ -299,12 +308,20 @@ $watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1)
<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">
@@ -344,15 +361,15 @@ $watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1)
<?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; ?>
@@ -360,8 +377,7 @@ $watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1)
</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">
+58 -10
View File
@@ -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,25 +15,63 @@ 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.
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
saas_logout();
header('Location: landing.php?tenant_deleted=1');
exit;
$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;
}
}
}
@@ -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
+4
View File
@@ -112,7 +112,11 @@ 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>
</footer>
<script src="assets/js/jquery.min.js"></script>
+4
View File
@@ -105,7 +105,11 @@ 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>
</footer>
<script src="assets/js/jquery.min.js"></script>
+13 -9
View File
@@ -1,4 +1,4 @@
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
<?php require_once __DIR__ . '/app/legal.php'; ?>
<!DOCTYPE HTML>
<html lang="de">
<head>
@@ -52,9 +52,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,11 +73,7 @@
</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(); ?>
<script src="assets/js/jquery.min.js"></script>
<script src="assets/js/browser.min.js"></script>
+40
View File
@@ -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;
+38
View File
@@ -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;
+48 -3
View File
@@ -3,6 +3,7 @@
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';
$pdo = app_db_pdo();
$errors = [];
@@ -11,6 +12,7 @@ $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') {
@@ -26,13 +28,30 @@ 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']);
}
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'];
}
@@ -68,7 +87,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">
@@ -85,6 +104,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<form method="post" action="register.php">
<?php echo app_csrf_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 +137,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 (Stand <?php echo saas_html(APP_TERMS_VERSION); ?>)</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 (Stand <?php echo saas_html(APP_PRIVACY_VERSION); ?>)</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 (Stand <?php echo saas_html(APP_DPA_VERSION); ?>)</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,7 +165,11 @@ 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>
</footer>
<script src="assets/js/jquery.min.js"></script>
+5
View File
@@ -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
+154
View File
@@ -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";
+4
View File
@@ -53,6 +53,10 @@ $result = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
auth_check_assert('registration succeeds', $result['ok'] === true, $failures, $passes);
+4
View File
@@ -54,6 +54,10 @@ $registration = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
password_email_check_assert('registration succeeds', $registration['ok'] === true, $failures, $passes);
+6
View File
@@ -53,6 +53,10 @@ $registration = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
settings_check_assert('registration succeeds', $registration['ok'] === true, $failures, $passes);
@@ -79,6 +83,7 @@ if ($registration['ok']) {
// (pdf_row_height_px, payment_reminder_interval_days).
$update = saas_update_tenant_settings($pdo, $tenantId, [
'tenant_name' => 'M3 Settings Flow Updated',
'customer_type' => 'business',
'timezone' => 'Europe/Berlin',
'locale' => 'de-DE',
'currency_code' => 'EUR',
@@ -114,6 +119,7 @@ if ($registration['ok']) {
// Ab einem bezahlten Tarif entscheidet der Mandant selbst.
$paidInput = [
'tenant_name' => 'M3 Settings Flow Updated',
'customer_type' => 'business',
'timezone' => 'Europe/Berlin',
'locale' => 'de-DE',
'currency_code' => 'EUR',
@@ -58,6 +58,10 @@ $first = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
$second = saas_register_tenant_owner($pdo, [
'tenant_name' => 'M3 Tenant Choice B',
@@ -66,6 +70,10 @@ $second = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
tenant_resolution_assert('first registration succeeds', $first['ok'] === true, $failures, $passes);
+5
View File
@@ -10,6 +10,10 @@ $suffix = bin2hex(random_bytes(4));
$password = 'RoleMatrixTest123!';
$roles = ['owner', 'admin', 'treasurer', 'member', 'viewer'];
// Wiederholte lokale Testläufe dürfen nicht am produktiven Login-Limit für
// 127.0.0.1 scheitern und dadurch Rollenprüfungen als falsch positiv werten.
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
// Seite => Rollen, die Zugriff haben sollen. Alle anderen Rollen muessen
// abgewiesen werden. Muss mit den saas_user_has_role()-Aufrufen in den
// jeweiligen Dateien uebereinstimmen.
@@ -171,6 +175,7 @@ foreach ($userIds as $uid) {
}
$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
if ($failures !== []) {
echo "\nM8 role matrix check failed with " . count($failures) . " failure(s):\n";
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/bootstrap.php';
require_once __DIR__ . '/../app/database.php';
require_once __DIR__ . '/../app/legal.php';
$errors = [];
$warnings = [];
$requireValue = static function (string $name) use (&$errors): string {
$value = trim((string)app_env($name, ''));
if ($value === '') {
$errors[] = "{$name} ist nicht gesetzt.";
}
return $value;
};
if (app_env('APP_ENV', 'prod') !== 'prod') {
$errors[] = 'APP_ENV muss für die Freigabe auf prod stehen.';
}
$appHost = $requireValue('APP_HOST');
$baseUrl = $requireValue('APP_BASE_URL');
if ($baseUrl !== '' && (!str_starts_with($baseUrl, 'https://') || parse_url($baseUrl, PHP_URL_HOST) !== $appHost)) {
$errors[] = 'APP_BASE_URL muss eine HTTPS-URL auf APP_HOST sein.';
}
$phone = app_legal_phone();
if (strlen(preg_replace('/\D/', '', $phone) ?? '') < 7) {
$errors[] = 'LEGAL_PHONE fehlt oder ist unplausibel; B2C darf so nicht freigeschaltet werden.';
}
if (!filter_var(app_legal_email(), FILTER_VALIDATE_EMAIL)) {
$errors[] = 'LEGAL_EMAIL ist ungültig.';
}
if (!str_starts_with(app_legal_ticket_url(), 'https://')) {
$errors[] = 'LEGAL_TICKET_URL muss HTTPS verwenden.';
}
if (app_env('APP_MAIL_TRANSPORT', 'mail') === 'log') {
$errors[] = 'APP_MAIL_TRANSPORT darf in Produktion nicht log sein.';
}
if (!filter_var((string)app_env('APP_MAIL_FROM', ''), FILTER_VALIDATE_EMAIL)) {
$errors[] = 'APP_MAIL_FROM ist nicht als gültige E-Mail-Adresse konfiguriert.';
}
foreach (['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET', 'DOLIBARR_API_KEY'] as $secretName) {
if (strlen(trim((string)app_env($secretName, ''))) < 16) {
$errors[] = "{$secretName} fehlt oder ist unplausibel.";
}
}
foreach ([APP_ROOT . '/env.local.php', APP_ROOT . '/.env.local'] as $secretFile) {
if (!is_file($secretFile)) {
continue;
}
$mode = fileperms($secretFile) & 0777;
if (($mode & 0077) !== 0) {
$errors[] = basename($secretFile) . ' ist zu weit lesbar; Dateirechte auf 0600 setzen.';
}
}
$mainCss = file_get_contents(APP_ROOT . '/assets/css/main.css') ?: '';
if (preg_match('~@import\s+url\(["\']?https?://|url\(["\']?https?://~i', $mainCss)) {
$errors[] = 'assets/css/main.css lädt noch externe Ressourcen.';
}
try {
$pdo = app_db_pdo();
foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts'] as $table) {
$stmt = $pdo->prepare('SHOW TABLES LIKE ?');
$stmt->execute([$table]);
if ($stmt->fetchColumn() === false) {
$errors[] = "Datenbanktabelle {$table} fehlt; Migrationen ausführen.";
}
}
$stmt = $pdo->prepare('SELECT COUNT(*) FROM schema_migrations WHERE version = ?');
$stmt->execute(['0030_contract_termination.sql']);
if ((int)$stmt->fetchColumn() !== 1) {
$errors[] = 'Migration 0030_contract_termination.sql ist nicht angewendet.';
}
$unknownTenants = (int)$pdo->query("SELECT COUNT(*) FROM tenants WHERE customer_type = 'unknown'")->fetchColumn();
if ($unknownTenants > 0) {
$warnings[] = "{$unknownTenants} bestehende Mandant(en) haben noch keinen Vertragstyp; vor kostenpflichtiger Buchung festlegen.";
}
} catch (Throwable $e) {
$errors[] = 'Datenbankprüfung fehlgeschlagen: ' . $e->getMessage();
}
foreach ($warnings as $warning) {
echo "WARNUNG: {$warning}\n";
}
if ($errors !== []) {
foreach ($errors as $error) {
echo "FEHLER: {$error}\n";
}
echo "\nProduktionsfreigabe: NICHT BEREIT\n";
exit(1);
}
echo "Produktionsfreigabe: technische Pflichtprüfungen bestanden.\n";
+31 -5
View File
@@ -35,12 +35,12 @@ $checks = [
[
'label' => 'Impressum',
'path' => 'impressum.php',
'contains' => ['Impressum', 'In den Sieben Stücken 9d', '30655 Hannover'],
'contains' => ['Impressum', 'In den Sieben Stücken 9d', '30655 Hannover', 'ticketsystem.ctb-it.de'],
],
[
'label' => 'AGB',
'path' => 'agb.php',
'contains' => ['Allgemeine Geschäftsbedingungen', 'Entwurf'],
'contains' => ['Allgemeine Geschäftsbedingungen', 'Verbraucher', 'Verträge hier kündigen'],
],
[
'label' => 'Preise',
@@ -52,6 +52,26 @@ $checks = [
'path' => 'datenschutz.php',
'contains' => ['Datenschutzerklärung', 'Verantwortlicher', 'Auftragsverarbeiter'],
],
[
'label' => 'AVV',
'path' => 'avv.php',
'contains' => ['Auftragsverarbeitungsvertrag', 'Technische und organisatorische Maßnahmen', 'netcup'],
],
[
'label' => 'Widerrufsbelehrung',
'path' => 'widerruf.php',
'contains' => ['Widerrufsbelehrung', 'vierzehn Tagen', 'Vertrag widerrufen'],
],
[
'label' => 'Elektronische Widerrufsfunktion',
'path' => 'widerrufen.php',
'contains' => ['Vertrag widerrufen', 'Widerruf prüfen'],
],
[
'label' => 'Kündigungsschaltfläche',
'path' => 'kuendigen.php',
'contains' => ['Vertrag kündigen', 'Kündigung prüfen'],
],
[
'label' => 'Dashboard',
'path' => 'index.php',
@@ -396,6 +416,10 @@ $passes = 0;
echo "HTTP smoke base URL: {$baseUrl}\n";
// Der Smoke-Test erzeugt absichtlich einen Login. Frühere lokale Testläufe
// dürfen diesen nicht über das produktive IP-Limit beeinflussen.
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
// Testmandant anlegen und per HTTP anmelden.
try {
$seeded = smoke_seed($pdo, $smokeSlug, $smokeEmail, $smokePassword, $suffix);
@@ -415,16 +439,17 @@ foreach ($checks as $i => $check) {
$authCookies = [];
$loginPage = smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies);
preg_match('/name="csrf_token" value="([^"]+)"/', $loginPage['body'], $csrfMatch);
smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies, 'POST', http_build_query([
$loginResponse = smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies, 'POST', http_build_query([
'csrf_token' => $csrfMatch[1] ?? '',
'email' => $smokeEmail,
'password' => $smokePassword,
'tenant_slug' => $smokeSlug,
]));
if (empty($authCookies)) {
if ($loginResponse['status'] !== 302 || !str_contains((string)$loginResponse['location'], 'index.php')) {
smoke_cleanup($pdo, $smokeTenantId, $smokeEmail);
fwrite(STDERR, "HTTP smoke: Anmeldung fehlgeschlagen (keine Session).\n");
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
fwrite(STDERR, "HTTP smoke: Anmeldung fehlgeschlagen (kein Redirect auf index.php).\n");
exit(1);
}
@@ -519,6 +544,7 @@ foreach ($skippedUnsafe as $skip) {
}
smoke_cleanup($pdo, $smokeTenantId, $smokeEmail);
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
if ($failures !== []) {
echo "\nHTTP smoke failed with " . count($failures) . " failure(s):\n";
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
require_once __DIR__ . '/../app/tenant-logo.php';
$pdo = dev_pdo();
$dryRun = in_array('--dry-run', $argv, true);
$stmt = $pdo->query(
"SELECT t.id, t.slug, ts.brand_logo, ts.pdf_watermark_logo,
tb.plan_code, tb.subscription_status
FROM tenants t
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
LEFT JOIN tenant_billing tb ON tb.tenant_id = t.id
WHERE t.contract_ends_at IS NOT NULL
AND t.contract_ends_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
AND t.slug <> 'default'
ORDER BY t.id"
);
$tenants = $stmt->fetchAll();
$deleted = 0;
$skipped = 0;
foreach ($tenants as $tenant) {
if ((string)($tenant['plan_code'] ?? 'free') !== 'free'
&& (string)($tenant['subscription_status'] ?? '') !== 'canceled'
) {
$skipped++;
echo "SKIP {$tenant['slug']}: kostenpflichtiges Abo ist noch nicht beendet.\n";
continue;
}
if ($dryRun) {
echo "WOULD_DELETE {$tenant['slug']}\n";
continue;
}
$memberStmt = $pdo->prepare('SELECT user_id FROM tenant_memberships WHERE tenant_id = ?');
$memberStmt->execute([(int)$tenant['id']]);
$userIds = array_map('intval', $memberStmt->fetchAll(PDO::FETCH_COLUMN));
try {
$pdo->beginTransaction();
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([(int)$tenant['id']]);
foreach ($userIds as $userId) {
$membershipStmt = $pdo->prepare('SELECT COUNT(*) FROM tenant_memberships WHERE user_id = ?');
$membershipStmt->execute([$userId]);
$adminStmt = $pdo->prepare('SELECT COUNT(*) FROM platform_admins WHERE user_id = ?');
$adminStmt->execute([$userId]);
if ((int)$membershipStmt->fetchColumn() === 0 && (int)$adminStmt->fetchColumn() === 0) {
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
}
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$skipped++;
echo "ERROR {$tenant['slug']}: {$e->getMessage()}\n";
continue;
}
tenant_logo_delete((string)($tenant['brand_logo'] ?? ''));
tenant_logo_delete((string)($tenant['pdf_watermark_logo'] ?? ''));
$deleted++;
echo "DELETED {$tenant['slug']}\n";
}
echo "Fertig: {$deleted} gelöscht, {$skipped} übersprungen.\n";
exit($skipped > 0 ? 1 : 0);
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
$pdo = dev_pdo();
$dryRun = in_array('--dry-run', $argv, true);
$rules = [
// Bei stuendlichem Lauf bleibt der echte Maximalwert unter 24 Stunden.
'rate_limit_attempts' => ['rate_limit_attempts', 'created_at < DATE_SUB(NOW(), INTERVAL 23 HOUR)'],
// Taeglicher/stuendlicher Lauf mit einer Tagesreserve: hoechstens 180 Tage.
'audit_log' => ['audit_log', 'created_at < DATE_SUB(NOW(), INTERVAL 179 DAY)'],
'outbound_emails' => ['outbound_emails', 'created_at < DATE_SUB(NOW(), INTERVAL 179 DAY)'],
'auth_tokens' => ['user_auth_tokens', 'expires_at < NOW() OR consumed_at < DATE_SUB(NOW(), INTERVAL 1 DAY)'],
'stripe_webhook_events' => ['stripe_webhook_events', 'processed_at < DATE_SUB(NOW(), INTERVAL 2 YEAR)'],
// Allgemeine Verjährung: erst nach drei vollständig abgelaufenen
// Kalenderjahren. Offene Fälle und Nachweise aktiver Verträge bleiben.
'processed_legal_requests' => ['legal_requests', "status = 'processed' AND received_at < MAKEDATE(YEAR(CURDATE()) - 3, 1)"],
'detached_legal_acceptances' => ['legal_acceptances', 'tenant_id IS NULL AND accepted_at < MAKEDATE(YEAR(CURDATE()) - 3, 1)'],
];
$failed = false;
foreach ($rules as $label => [$table, $where]) {
try {
if ($dryRun) {
$count = (int)$pdo->query("SELECT COUNT(*) FROM {$table} WHERE {$where}")->fetchColumn();
echo "{$label}: {$count} würde(n) gelöscht\n";
continue;
}
$count = $pdo->exec("DELETE FROM {$table} WHERE {$where}");
echo "{$label}: " . (int)$count . " gelöscht\n";
} catch (Throwable $e) {
$failed = true;
echo "ERROR {$label}: {$e->getMessage()}\n";
}
}
exit($failed ? 1 : 0);
+11 -1
View File
@@ -8,7 +8,17 @@ $scheme = app_is_https() ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
$base = $host !== '' ? $scheme . '://' . $host : '';
$pages = ['', 'preise.php', 'impressum.php', 'agb.php', 'datenschutz.php'];
$pages = [
'',
'preise.php',
'impressum.php',
'agb.php',
'datenschutz.php',
'avv.php',
'widerruf.php',
'widerrufen.php',
'kuendigen.php',
];
header('Content-Type: application/xml; charset=utf-8');
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
+11 -1
View File
@@ -6,6 +6,7 @@ require_once __DIR__ . '/app/database.php';
require_once __DIR__ . '/app/billing.php';
require_once __DIR__ . '/app/audit.php';
require_once __DIR__ . '/app/dolibarr.php';
require_once __DIR__ . '/app/saas-mail.php';
// Kein app_require_csrf()/saas_require_login(): Stripe ruft diesen
// Endpunkt unauthentifiziert von aussen auf. Die Echtheit wird
@@ -55,6 +56,12 @@ switch ($event['type']) {
'stripe_subscription_id' => (string)$object['subscription'],
]);
app_audit_log($pdo, $tenantId, null, 'billing.subscription_started', 'tenant', $tenantId, ['plan_code' => $planCode]);
$confirmation = saas_send_paid_contract_confirmation($pdo, $tenantId, $planCode);
if (!$confirmation['ok']) {
app_audit_log($pdo, $tenantId, null, 'billing.contract_confirmation_failed', 'tenant', $tenantId, [
'error' => $confirmation['error'] ?? 'unknown',
]);
}
}
break;
@@ -63,6 +70,9 @@ switch ($event['type']) {
$tenantId ??= billing_find_tenant_id_by_stripe_subscription($pdo, (string)($object['id'] ?? ''));
if ($tenantId !== null) {
$status = (string)($object['status'] ?? 'active');
if (!empty($object['cancel_at_period_end']) && in_array($status, ['active', 'trialing', 'past_due'], true)) {
$status = 'canceling';
}
$periodEnd = isset($object['current_period_end'])
? date('Y-m-d H:i:s', (int)$object['current_period_end'])
: null;
@@ -77,7 +87,7 @@ switch ($event['type']) {
// eindeutiger Zuordnung, sonst bleibt der bisherige Plan bestehen.
$lookupKey = (string)($object['items']['data'][0]['price']['lookup_key'] ?? '');
$mappedPlan = billing_plan_code_by_lookup_key($lookupKey);
if ($mappedPlan !== null && in_array($status, ['active', 'trialing', 'past_due'], true)) {
if ($mappedPlan !== null && in_array($status, ['active', 'trialing', 'past_due', 'canceling'], true)) {
$updateFields['plan_code'] = $mappedPlan;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
require_once __DIR__ . '/app/legal.php';
?>
<!DOCTYPE HTML>
<html lang="de">
<head>
<title>Widerrufsbelehrung 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">
</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>Widerrufsbelehrung</h1></header>
<p><a href="widerrufen.php" class="button primary big">Vertrag widerrufen</a></p>
<?php echo app_render_legal_document('withdrawal_information'); ?>
<p><a href="legal/widerruf-<?php echo htmlspecialchars(APP_WITHDRAWAL_VERSION, ENT_QUOTES, 'UTF-8'); ?>.txt" download>Widerrufsbelehrung und Musterformular speichern</a></p>
<p><a href="landing.php">&larr; Zurück zur Startseite</a></p>
</section>
</div>
<?php echo app_public_legal_footer(); ?>
<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>
+121
View File
@@ -0,0 +1,121 @@
<?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');
$values = [
'requester_name' => trim((string)($_POST['requester_name'] ?? '')),
'requester_email' => trim((string)($_POST['requester_email'] ?? '')),
'contract_reference' => strtolower(trim((string)($_POST['contract_reference'] ?? ''))),
'request_reason' => trim((string)($_POST['request_reason'] ?? '')),
];
$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, 'withdrawal');
}
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' => 'withdrawal'];
$stored = app_store_legal_request($pdo, $input);
$processing = app_process_withdrawal_request($pdo, $stored);
$mailStatus = app_send_legal_request_confirmations($input, $stored, $processing);
$stage = 'complete';
}
} elseif ($stage !== 'form') {
$stage = 'review';
}
}
?>
<!DOCTYPE HTML>
<html lang="de">
<head>
<title>Vertrag widerrufen 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">
</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 widerrufen</h1></header>
<?php if ($stage === 'complete' && $stored !== null && $processing !== null): ?>
<div class="hint-box success">
<h2>Widerruf eingegangen</h2>
<p>Referenz: <b><?php echo $h($stored['reference_code']); ?></b><br>
Eingang: <?php echo $h($stored['received_at']); ?> Uhr (Europe/Berlin)</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>Der Widerruf 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']); ?>&amp;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>Widerruf 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']); ?></p>
<?php if ($values['request_reason'] !== ''): ?><p>Ergänzung: <?php echo nl2br($h($values['request_reason'])); ?></p><?php endif; ?>
<form method="post" action="widerrufen.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">Widerruf bestätigen</button></li>
<li><button type="submit" name="stage" value="form" class="button">Angaben ändern</button></li>
</ul>
</form>
<?php else: ?>
<p>Verbraucher können hier während der Widerrufsfrist ihren Kaffeeliste-Vertrag widerrufen. Eine Begründung und eine Anmeldung sind nicht erforderlich.</p>
<p><a href="widerruf.php">Widerrufsbelehrung und Musterformular anzeigen</a></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="widerrufen.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>
<label for="request_reason">Optionale Ergänzung</label>
<textarea name="request_reason" id="request_reason" maxlength="5000" rows="4"><?php echo $h($values['request_reason']); ?></textarea>
<button type="submit" class="primary">Widerruf prüfen</button>
</form>
<?php endif; ?>
</section>
</div>
<?php echo app_public_legal_footer(); ?>
<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>