Rechtstexte und B2C-Vertragsabläufe absichern
This commit is contained in:
@@ -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,
|
# Verzeichnisse (Sessions, Mail-Logs, Migrationen, CLI-Skripte, Doku,
|
||||||
# App-Bausteine, Legacy-DB-Shim, Vendor-Bibliotheken), die Git-Metadaten und
|
# App-Bausteine, Legacy-DB-Shim, Vendor-Bibliotheken), die Git-Metadaten und
|
||||||
# Konfigurationsdateien mit Zugangsdaten duerfen nicht direkt per URL
|
# Konfigurationsdateien mit Zugangsdaten duerfen nicht direkt per URL
|
||||||
@@ -19,6 +20,12 @@
|
|||||||
RewriteCond %{REQUEST_URI} ^/(var|database|scripts|docs|app|lib)/ [NC]
|
RewriteCond %{REQUEST_URI} ^/(var|database|scripts|docs|app|lib)/ [NC]
|
||||||
RewriteRule ^ - [F]
|
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
|
# Vendor-Verzeichnisse werden ausschliesslich serverseitig eingebunden
|
||||||
# (TCPDF ueber exportKaffeeliste.php); direkte URL-Aufrufe ihrer PHP-
|
# (TCPDF ueber exportKaffeeliste.php); direkte URL-Aufrufe ihrer PHP-
|
||||||
# Dateien sind nie erwuenscht.
|
# Dateien sind nie erwuenscht.
|
||||||
|
|||||||
@@ -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
@@ -4,6 +4,7 @@ require_once __DIR__ . '/functions.php';
|
|||||||
require_once __DIR__ . '/app/billing.php';
|
require_once __DIR__ . '/app/billing.php';
|
||||||
require_once __DIR__ . '/app/audit.php';
|
require_once __DIR__ . '/app/audit.php';
|
||||||
require_once __DIR__ . '/app/saas-mail.php';
|
require_once __DIR__ . '/app/saas-mail.php';
|
||||||
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$user = saas_require_login();
|
$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.');
|
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);
|
$billing = billing_fetch_or_init($pdo, $tenantId);
|
||||||
$baseUrl = saas_app_url('mandant-einstellungen.php');
|
$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 {
|
try {
|
||||||
// Fall 1: Wechsel auf "free" (kein Stripe-Preis) mit bestehender bezahlter
|
// Wechsel auf "free": die bezahlte Subscription wird zum Ende der bereits
|
||||||
// Subscription -> echtes Kuendigen der Subscription, kein Checkout noetig.
|
// bezahlten Periode gekündigt. Bis dahin bleiben Tarif und Funktionen aktiv.
|
||||||
if ($plans[$planCode]['stripe_lookup_key'] === null) {
|
if ($plans[$planCode]['stripe_lookup_key'] === null) {
|
||||||
if ($planCode !== 'free') {
|
if ($planCode !== 'free') {
|
||||||
// enterprise hat ebenfalls keinen Stripe-Preis, ist aber kein
|
// enterprise hat ebenfalls keinen Stripe-Preis, ist aber kein
|
||||||
@@ -49,7 +60,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($billing['stripe_subscription_id'] !== null) {
|
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']) {
|
if (!$cancelResult['ok']) {
|
||||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.downgrade_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $cancelResult['error']]);
|
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.downgrade_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $cancelResult['error']]);
|
||||||
http_response_code(502);
|
http_response_code(502);
|
||||||
@@ -57,11 +68,20 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
billing_update($pdo, $tenantId, [
|
if ($billing['stripe_subscription_id'] === null) {
|
||||||
'plan_code' => 'free',
|
billing_update($pdo, $tenantId, [
|
||||||
'subscription_status' => 'canceled',
|
'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');
|
header('Location: ' . $baseUrl . '?upgrade=success');
|
||||||
exit;
|
exit;
|
||||||
@@ -73,10 +93,24 @@ try {
|
|||||||
exit('Der Tarif ist bei Stripe aktuell nicht verfügbar. Bitte später erneut versuchen.');
|
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 ->
|
// Fall 2: Es gibt bereits eine aktive/bezahlte Subscription bei Stripe ->
|
||||||
// Preis in-place wechseln (Up- oder Downgrade zwischen bezahlten Stufen),
|
// Preis in-place wechseln (Up- oder Downgrade zwischen bezahlten Stufen),
|
||||||
// statt eine weitere Checkout-Session zu erzeugen. Stripe rechnet die
|
// 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) {
|
if ($billing['stripe_subscription_id'] !== null) {
|
||||||
$subscription = stripe_get_subscription((string)$billing['stripe_subscription_id']);
|
$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]);
|
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');
|
header('Location: ' . $baseUrl . '?upgrade=success');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +1,29 @@
|
|||||||
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
|
<?php
|
||||||
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<title>AGB – Kaffeeliste</title>
|
<title>AGB – Kaffeeliste</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="assets/css/main.css" />
|
<link rel="stylesheet" href="assets/css/main.css">
|
||||||
<link rel="stylesheet" href="assets/css/public.css" />
|
<link rel="stylesheet" href="assets/css/public.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="is-preload public-page">
|
<body class="is-preload public-page">
|
||||||
<div class="public-shell">
|
<div class="public-shell">
|
||||||
<section class="public-section public-legal">
|
<section class="public-section public-legal">
|
||||||
<nav class="public-nav" aria-label="Hauptnavigation">
|
<nav class="public-nav" aria-label="Hauptnavigation">
|
||||||
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
||||||
<ul class="actions">
|
<ul class="actions"><li><a href="login.php" class="button">Login</a></li></ul>
|
||||||
<li><a href="login.php" class="button">Login</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
<header class="major"><h1>Allgemeine Geschäftsbedingungen</h1></header>
|
||||||
<header class="major">
|
<?php echo app_render_legal_document('terms'); ?>
|
||||||
<h1>Allgemeine Geschäftsbedingungen</h1>
|
<p><a href="legal/agb-<?php echo htmlspecialchars(APP_TERMS_VERSION, ENT_QUOTES, 'UTF-8'); ?>.txt" download>AGB als Textdatei speichern</a></p>
|
||||||
</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>
|
|
||||||
|
|
||||||
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<?php echo app_public_legal_footer(); ?>
|
||||||
<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>
|
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
<script src="assets/js/browser.min.js"></script>
|
<script src="assets/js/browser.min.js"></script>
|
||||||
<script src="assets/js/breakpoints.min.js"></script>
|
<script src="assets/js/breakpoints.min.js"></script>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function app_audit_log(
|
|||||||
?int $subjectId,
|
?int $subjectId,
|
||||||
array $metadata = []
|
array $metadata = []
|
||||||
): void {
|
): void {
|
||||||
|
$pdo->exec('DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'INSERT INTO audit_log (tenant_id, actor_user_id, action, subject_type, subject_id, metadata_json, ip)
|
'INSERT INTO audit_log (tenant_id, actor_user_id, action, subject_type, subject_id, metadata_json, ip)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
|||||||
+54
-1
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/tenant-logo.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assembles a full export of everything stored for one tenant (data
|
* 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
|
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]);
|
$stmt->execute([$tenantId]);
|
||||||
$tenant = $stmt->fetch();
|
$tenant = $stmt->fetch();
|
||||||
|
|
||||||
@@ -66,6 +67,51 @@ function app_export_tenant_data(PDO $pdo, int $tenantId): array
|
|||||||
$stmt->execute([$tenantId]);
|
$stmt->execute([$tenantId]);
|
||||||
$auditLog = $stmt->fetchAll();
|
$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 [
|
return [
|
||||||
'exported_at' => date('c'),
|
'exported_at' => date('c'),
|
||||||
'tenant' => $tenant ?: null,
|
'tenant' => $tenant ?: null,
|
||||||
@@ -78,5 +124,12 @@ function app_export_tenant_data(PDO $pdo, int $tenantId): array
|
|||||||
'payment_import_rows' => $importRows,
|
'payment_import_rows' => $importRows,
|
||||||
'outbound_emails' => $outboundEmails,
|
'outbound_emails' => $outboundEmails,
|
||||||
'audit_log' => $auditLog,
|
'audit_log' => $auditLog,
|
||||||
|
'faq_entries' => $faqEntries,
|
||||||
|
'paypal_payments' => $paypalPayments,
|
||||||
|
'billing' => $billing ?: null,
|
||||||
|
'features' => $features,
|
||||||
|
'legal_acceptances' => $legalAcceptances,
|
||||||
|
'legal_requests' => $legalRequests,
|
||||||
|
'logos' => $logos,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -110,6 +110,12 @@ function app_feature_enabled(PDO $pdo, int $tenantId, string $featureKey): bool
|
|||||||
{
|
{
|
||||||
static $cache = [];
|
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) {
|
if ($tenantId <= 0) {
|
||||||
return app_feature_default($featureKey);
|
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) {
|
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]);
|
$stmt->execute([$tenantId, $key, $enabled ? 1 : 0]);
|
||||||
if (($before[$key] ?? true) !== $enabled) {
|
if (($before[$key] ?? true) !== $enabled) {
|
||||||
$changed[] = $key;
|
$changed[] = $key;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]);
|
$pdo->prepare('INSERT INTO rate_limit_attempts (bucket) VALUES (?)')->execute([$bucket]);
|
||||||
|
|
||||||
// Opportunistic cleanup so the table doesn't grow unbounded; scoped to
|
// Globaler Maximalhorizont: unabhängig davon, ob derselbe Bucket je wieder
|
||||||
// this bucket to keep each call cheap.
|
// benutzt wird, bleibt keine IP-basierte Rate-Limit-Zeile länger als 24h.
|
||||||
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ? AND created_at < DATE_SUB(NOW(), INTERVAL ? SECOND)')
|
$pdo->exec('DELETE FROM rate_limit_attempts WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)');
|
||||||
->execute([$bucket, $windowSeconds]);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'SELECT COUNT(*) FROM rate_limit_attempts WHERE bucket = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)'
|
'SELECT COUNT(*) FROM rate_limit_attempts WHERE bucket = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)'
|
||||||
|
|||||||
+42
-8
@@ -6,6 +6,7 @@ require_once __DIR__ . '/bootstrap.php';
|
|||||||
require_once __DIR__ . '/database.php';
|
require_once __DIR__ . '/database.php';
|
||||||
require_once __DIR__ . '/faq.php';
|
require_once __DIR__ . '/faq.php';
|
||||||
require_once __DIR__ . '/billing.php';
|
require_once __DIR__ . '/billing.php';
|
||||||
|
require_once __DIR__ . '/legal.php';
|
||||||
|
|
||||||
function saas_email_norm(string $email): string
|
function saas_email_norm(string $email): string
|
||||||
{
|
{
|
||||||
@@ -55,7 +56,7 @@ function saas_normalize_host(?string $host): string
|
|||||||
|
|
||||||
function saas_primary_app_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
|
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 = ?
|
WHERE td.domain_norm = ?
|
||||||
AND td.status = ?
|
AND td.status = ?
|
||||||
AND t.status = ?
|
AND t.status = ?
|
||||||
|
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||||
LIMIT 1'
|
LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute([$host, 'active', 'active']);
|
$stmt->execute([$host, 'active', 'active']);
|
||||||
@@ -166,6 +168,8 @@ function saas_current_user(?PDO $pdo = null): ?array
|
|||||||
t.id AS tenant_id,
|
t.id AS tenant_id,
|
||||||
t.slug AS tenant_slug,
|
t.slug AS tenant_slug,
|
||||||
t.name AS tenant_name,
|
t.name AS tenant_name,
|
||||||
|
t.customer_type,
|
||||||
|
t.contract_ends_at,
|
||||||
t.status AS tenant_status,
|
t.status AS tenant_status,
|
||||||
tm.role,
|
tm.role,
|
||||||
tm.status AS membership_status
|
tm.status AS membership_status
|
||||||
@@ -177,6 +181,7 @@ function saas_current_user(?PDO $pdo = null): ?array
|
|||||||
AND u.status = ?
|
AND u.status = ?
|
||||||
AND tm.status = ?
|
AND tm.status = ?
|
||||||
AND t.status = ?
|
AND t.status = ?
|
||||||
|
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||||
LIMIT 1'
|
LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
$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.id AS tenant_id,
|
||||||
t.slug,
|
t.slug,
|
||||||
t.name,
|
t.name,
|
||||||
|
t.customer_type,
|
||||||
t.status,
|
t.status,
|
||||||
t.timezone,
|
t.timezone,
|
||||||
t.locale,
|
t.locale,
|
||||||
@@ -312,6 +318,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
|||||||
'tenant_id' => (int)$settings['tenant_id'],
|
'tenant_id' => (int)$settings['tenant_id'],
|
||||||
'slug' => (string)$settings['slug'],
|
'slug' => (string)$settings['slug'],
|
||||||
'name' => (string)$settings['name'],
|
'name' => (string)$settings['name'],
|
||||||
|
'customer_type' => (string)$settings['customer_type'],
|
||||||
'status' => (string)$settings['status'],
|
'status' => (string)$settings['status'],
|
||||||
'timezone' => (string)$settings['timezone'],
|
'timezone' => (string)$settings['timezone'],
|
||||||
'locale' => (string)$settings['locale'],
|
'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
|
function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): array
|
||||||
{
|
{
|
||||||
$tenantName = trim((string)($input['tenant_name'] ?? ''));
|
$tenantName = trim((string)($input['tenant_name'] ?? ''));
|
||||||
|
$customerType = (string)($input['customer_type'] ?? '');
|
||||||
$timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin'));
|
$timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin'));
|
||||||
$locale = trim((string)($input['locale'] ?? 'de-DE'));
|
$locale = trim((string)($input['locale'] ?? 'de-DE'));
|
||||||
$currencyCode = strtoupper(trim((string)($input['currency_code'] ?? 'EUR')));
|
$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) {
|
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||||
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
|
$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)) {
|
if (!in_array($timezone, DateTimeZone::listIdentifiers(), true)) {
|
||||||
$errors[] = 'Die Zeitzone ist ungültig.';
|
$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(
|
$stmt = $pdo->prepare(
|
||||||
'UPDATE tenants
|
'UPDATE tenants
|
||||||
SET name = ?, timezone = ?, locale = ?, currency_code = ?
|
SET name = ?, customer_type = ?, timezone = ?, locale = ?, currency_code = ?
|
||||||
WHERE id = ?'
|
WHERE id = ?'
|
||||||
);
|
);
|
||||||
$stmt->execute([$tenantName, $timezone, $locale, $currencyCode, $tenantId]);
|
$stmt->execute([$tenantName, $customerType, $timezone, $locale, $currencyCode, $tenantId]);
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'INSERT INTO tenant_settings
|
'INSERT INTO tenant_settings
|
||||||
@@ -554,7 +565,8 @@ function saas_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = '
|
|||||||
WHERE u.email_norm = ?
|
WHERE u.email_norm = ?
|
||||||
AND u.status = ?
|
AND u.status = ?
|
||||||
AND tm.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
|
ORDER BY
|
||||||
CASE tm.role
|
CASE tm.role
|
||||||
WHEN \'owner\' THEN 1
|
WHEN \'owner\' THEN 1
|
||||||
@@ -593,6 +605,7 @@ function saas_list_user_memberships(PDO $pdo, int $userId): array
|
|||||||
AND u.status = ?
|
AND u.status = ?
|
||||||
AND tm.status = ?
|
AND tm.status = ?
|
||||||
AND t.status = ?
|
AND t.status = ?
|
||||||
|
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE tm.role
|
CASE tm.role
|
||||||
WHEN \'owner\' THEN 1
|
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 u.status = ?
|
||||||
AND tm.status = ?
|
AND tm.status = ?
|
||||||
AND t.status = ?
|
AND t.status = ?
|
||||||
|
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||||
LIMIT 1'
|
LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
$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
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
WHERE tm.user_id = ?
|
WHERE tm.user_id = ?
|
||||||
AND tm.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
|
ORDER BY
|
||||||
CASE tm.role
|
CASE tm.role
|
||||||
WHEN \'owner\' THEN 1
|
WHEN \'owner\' THEN 1
|
||||||
@@ -1206,6 +1221,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
|||||||
$emailNorm = saas_email_norm($email);
|
$emailNorm = saas_email_norm($email);
|
||||||
$password = (string)($input['password'] ?? '');
|
$password = (string)($input['password'] ?? '');
|
||||||
$passwordConfirm = (string)($input['password_confirm'] ?? '');
|
$passwordConfirm = (string)($input['password_confirm'] ?? '');
|
||||||
|
$customerType = (string)($input['customer_type'] ?? '');
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||||
@@ -1226,6 +1242,18 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
|||||||
if ($password !== $passwordConfirm) {
|
if ($password !== $passwordConfirm) {
|
||||||
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
|
$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 !== []) {
|
if ($errors !== []) {
|
||||||
return ['ok' => false, 'errors' => $errors];
|
return ['ok' => false, 'errors' => $errors];
|
||||||
@@ -1265,10 +1293,10 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
|||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code)
|
'INSERT INTO tenants (slug, name, customer_type, status, timezone, locale, currency_code)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)'
|
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();
|
$tenantId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$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]);
|
$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);
|
$verificationToken = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440);
|
||||||
|
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
@@ -1335,6 +1368,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
|||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'tenant_slug' => $tenantSlug,
|
'tenant_slug' => $tenantSlug,
|
||||||
'tenant_name' => $tenantName,
|
'tenant_name' => $tenantName,
|
||||||
|
'customer_type' => $customerType,
|
||||||
'role' => 'owner',
|
'role' => 'owner',
|
||||||
],
|
],
|
||||||
'email_verification_token' => $verificationToken['token'],
|
'email_verification_token' => $verificationToken['token'],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/legal.php';
|
||||||
|
|
||||||
function saas_mail_transport(): string
|
function saas_mail_transport(): string
|
||||||
{
|
{
|
||||||
@@ -172,6 +173,7 @@ function saas_log_outbound_email(
|
|||||||
?string $error,
|
?string $error,
|
||||||
?int $createdByUserId
|
?int $createdByUserId
|
||||||
): int {
|
): int {
|
||||||
|
$pdo->exec('DELETE FROM outbound_emails WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'INSERT INTO outbound_emails
|
'INSERT INTO outbound_emails
|
||||||
(tenant_id, participant_id, template, subject, status, sent_at, error, created_by_user_id)
|
(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);
|
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
|
* Textkoerper der automatischen Zahlungserinnerung an ein Mitglied mit
|
||||||
* offenem Betrag oberhalb der Warnschwelle des Mandanten.
|
* offenem Betrag oberhalb der Warnschwelle des Mandanten.
|
||||||
|
|||||||
+44
-6
@@ -151,6 +151,12 @@ function stripe_create_checkout_session(string $priceId, string $customerEmail,
|
|||||||
// .deleted-Events liefern kein Checkout-Session-Objekt, aber die
|
// .deleted-Events liefern kein Checkout-Session-Objekt, aber die
|
||||||
// Metadaten der Subscription, darueber loesen wir tenant_id auf.
|
// Metadaten der Subscription, darueber loesen wir tenant_id auf.
|
||||||
'subscription_data' => ['metadata' => $metadata],
|
'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) {
|
if ($existingCustomerId !== null) {
|
||||||
@@ -175,6 +181,17 @@ function stripe_create_billing_portal_session(string $customerId, string $return
|
|||||||
$result = stripe_request('POST', 'billing_portal/sessions', [
|
$result = stripe_request('POST', 'billing_portal/sessions', [
|
||||||
'customer' => $customerId,
|
'customer' => $customerId,
|
||||||
'return_url' => $returnUrl,
|
'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']) {
|
if (!$result['ok']) {
|
||||||
return ['ok' => false, 'url' => null, 'error' => $result['error']];
|
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
|
* Switches an existing active subscription to a different Price in place.
|
||||||
* (upgrade or downgrade between paid tiers), instead of creating a brand
|
* The billing cycle stays unchanged and no unclear mid-cycle debit/credit is
|
||||||
* new Checkout Session. Stripe prorates the difference automatically.
|
* created; the new full monthly amount applies at the next renewal.
|
||||||
* Also clears any pending cancel_at_period_end, since choosing a plan is
|
* Also clears any pending cancel_at_period_end, since choosing a plan is
|
||||||
* an explicit signal the tenant wants to keep the subscription running.
|
* 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}", [
|
$result = stripe_request('POST', "subscriptions/{$subscriptionId}", [
|
||||||
'items' => [['id' => $subscriptionItemId, 'price' => $newPriceId]],
|
'items' => [['id' => $subscriptionItemId, 'price' => $newPriceId]],
|
||||||
'proration_behavior' => 'create_prorations',
|
'proration_behavior' => 'none',
|
||||||
'cancel_at_period_end' => 'false',
|
'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
|
* Cancels a subscription immediately. This is reserved for exceptional
|
||||||
* tenant downgrades all the way to the free plan from the admin UI.
|
* 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}
|
* @return array{ok: bool, error: ?string}
|
||||||
*/
|
*/
|
||||||
@@ -286,6 +305,25 @@ function stripe_cancel_subscription(string $subscriptionId): array
|
|||||||
return ['ok' => true, 'error' => null];
|
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
|
* @return array{id: string, type: string, data: array}|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@import url(fontawesome-all.min.css);
|
@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
|
Editorial by HTML5 UP
|
||||||
html5up.net | @ajlkn
|
html5up.net | @ajlkn
|
||||||
|
|||||||
@@ -343,6 +343,14 @@ body.public-page {
|
|||||||
margin: 0 0.5rem;
|
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 {
|
.public-cta ul.actions {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">← 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>
|
||||||
@@ -9,6 +9,16 @@ $user = app_require_platform_admin($pdo);
|
|||||||
|
|
||||||
$tenants = app_backoffice_fetch_tenants($pdo);
|
$tenants = app_backoffice_fetch_tenants($pdo);
|
||||||
$billingPlans = billing_plans();
|
$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 'header.php';
|
||||||
include 'headerline.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.
|
<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>
|
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>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Kunde</th>
|
<th>Kunde</th>
|
||||||
@@ -44,6 +79,7 @@ include 'nav.php';
|
|||||||
<br><small>benötigt: <?php echo saas_html($billingPlans[$tenantRequiredPlan]['label'] ?? $tenantRequiredPlan); ?></small>
|
<br><small>benötigt: <?php echo saas_html($billingPlans[$tenantRequiredPlan]['label'] ?? $tenantRequiredPlan); ?></small>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</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 (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><?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>
|
<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
@@ -44,9 +44,10 @@ include 'nav.php';
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<h2>Datenexport</h2>
|
<h2>Datenexport</h2>
|
||||||
<p>Lädt alle für <?php echo saas_html($user['tenant_name']); ?> gespeicherten Daten als
|
<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,
|
JSON-Datei herunter: Einstellungen und Logos, Mitglieder, Zugänge und Rollen,
|
||||||
CSV-Importe, Mail-Versandlog und das Admin-Protokoll. Passwörter werden nicht
|
alle Buchungen, PayPal-Zuordnungen, Hinweise und FAQ, CSV-Importe, Tarif- und
|
||||||
exportiert.</p>
|
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">
|
<form method="post" action="datenexport.php">
|
||||||
<?php echo app_csrf_field(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
|
|||||||
+12
-108
@@ -1,125 +1,29 @@
|
|||||||
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
|
<?php
|
||||||
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<title>Datenschutzerklärung – Kaffeeliste</title>
|
<title>Datenschutzerklärung – Kaffeeliste</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="assets/css/main.css" />
|
<link rel="stylesheet" href="assets/css/main.css">
|
||||||
<link rel="stylesheet" href="assets/css/public.css" />
|
<link rel="stylesheet" href="assets/css/public.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="is-preload public-page">
|
<body class="is-preload public-page">
|
||||||
<div class="public-shell">
|
<div class="public-shell">
|
||||||
<section class="public-section public-legal">
|
<section class="public-section public-legal">
|
||||||
<nav class="public-nav" aria-label="Hauptnavigation">
|
<nav class="public-nav" aria-label="Hauptnavigation">
|
||||||
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
||||||
<ul class="actions">
|
<ul class="actions"><li><a href="login.php" class="button">Login</a></li></ul>
|
||||||
<li><a href="login.php" class="button">Login</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
<header class="major"><h1>Datenschutzerklärung</h1></header>
|
||||||
<header class="major">
|
<?php echo app_render_legal_document('privacy_notice'); ?>
|
||||||
<h1>Datenschutzerklärung</h1>
|
<p><a href="legal/datenschutz-<?php echo htmlspecialchars(APP_PRIVACY_VERSION, ENT_QUOTES, 'UTF-8'); ?>.txt" download>Datenschutzerklärung als Textdatei speichern</a></p>
|
||||||
</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>
|
|
||||||
|
|
||||||
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<?php echo app_public_legal_footer(); ?>
|
||||||
<footer class="public-footer-links">
|
|
||||||
<a href="impressum.php">Impressum</a>
|
|
||||||
<a href="agb.php">AGB</a>
|
|
||||||
<a href="datenschutz.php">Datenschutz</a>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
<script src="assets/js/browser.min.js"></script>
|
<script src="assets/js/browser.min.js"></script>
|
||||||
<script src="assets/js/breakpoints.min.js"></script>
|
<script src="assets/js/breakpoints.min.js"></script>
|
||||||
|
|||||||
+24
-15
@@ -94,9 +94,10 @@ mandant-einstellungen.php (Upgrade-/Portal-Buttons)
|
|||||||
als auch auf der Subscription selbst als Metadaten gesetzt, damit spätere
|
als auch auf der Subscription selbst als Metadaten gesetzt, damit spätere
|
||||||
Subscription-Events (die kein Checkout-Session-Objekt mehr enthalten)
|
Subscription-Events (die kein Checkout-Session-Objekt mehr enthalten)
|
||||||
trotzdem einem Mandanten zugeordnet werden können.
|
trotzdem einem Mandanten zugeordnet werden können.
|
||||||
- `abo-portal.php`: öffnet das Stripe Customer Portal für den bestehenden
|
- `abo-portal.php`: öffnet für den bestehenden Stripe-Kunden einen gezielten
|
||||||
Stripe-Kunden (Zahlungsmittel ändern, Abo kündigen) – Self-Service ohne
|
Customer-Portal-Flow ausschließlich zum Ändern des Zahlungsmittels. Die
|
||||||
eigene UI dafür.
|
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-webhook.php`: verifiziert die `Stripe-Signature` nach dem von
|
||||||
Stripe dokumentierten HMAC-SHA256-Schema (inklusive Zeitstempel-Toleranz
|
Stripe dokumentierten HMAC-SHA256-Schema (inklusive Zeitstempel-Toleranz
|
||||||
gegen Replay), verarbeitet `checkout.session.completed`,
|
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
|
unauthentifiziert von außen auf), stattdessen ausschließlich die
|
||||||
Signaturprüfung als Echtheitsnachweis.
|
Signaturprüfung als Echtheitsnachweis.
|
||||||
- `mandant-einstellungen.php`: zeigt bei Bedarf einen echten
|
- `mandant-einstellungen.php`: zeigt bei Bedarf einen echten
|
||||||
„Jetzt upgraden"-Button (führt zu Stripe Checkout) sowie, sobald ein
|
Bestellablauf (führt bei einer Neubuchung anschließend zu Stripe Checkout)
|
||||||
Stripe-Kunde existiert, „Zahlungsmethode verwalten / Abo kündigen"
|
sowie, sobald ein Stripe-Kunde existiert, „Zahlungsmethode verwalten"
|
||||||
(führt zum Customer Portal).
|
(gezielter Stripe-Portal-Flow).
|
||||||
|
|
||||||
**Bewusst nicht umgesetzt:** automatischer Stufenwechsel bei wachsender
|
**Bewusst nicht umgesetzt:** automatischer Stufenwechsel bei wachsender
|
||||||
Teilnehmerzahl. Stattdessen gilt eine harte Obergrenze (siehe unten):
|
Teilnehmerzahl. Stattdessen gilt eine harte Obergrenze (siehe unten):
|
||||||
@@ -168,7 +169,8 @@ scripts/check-billing-capacity.php (Regressionstest)
|
|||||||
`400` abgelehnt.
|
`400` abgelehnt.
|
||||||
- Customer Portal: mit einem echten, per API angelegten Stripe-Test-Kunden
|
- Customer Portal: mit einem echten, per API angelegten Stripe-Test-Kunden
|
||||||
liefert `abo-portal.php` einen echten 302-Redirect zu
|
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,
|
- Alle bestehenden Regressionstests (Golden Master, M8-Isolation,
|
||||||
M8-Rollenmatrix, HTTP-Smoke mit 36 Seiten) weiterhin grün.
|
M8-Rollenmatrix, HTTP-Smoke mit 36 Seiten) weiterhin grün.
|
||||||
- Alle Testdaten (Stripe-Testobjekte kosten nichts und wurden im
|
- Alle Testdaten (Stripe-Testobjekte kosten nichts und wurden im
|
||||||
@@ -271,8 +273,9 @@ solange kein Pflicht-Upgrade anstand.
|
|||||||
Umgesetzte Dateien:
|
Umgesetzte Dateien:
|
||||||
|
|
||||||
```text
|
```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)
|
app/billing.php (billing_plan_selectable_for)
|
||||||
|
abo-bestellen.php (Bestellzusammenfassung und Rechtstextbestätigung)
|
||||||
abo-upgrade.php (Fallunterscheidung Upgrade/Downgrade/Kündigung)
|
abo-upgrade.php (Fallunterscheidung Upgrade/Downgrade/Kündigung)
|
||||||
mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-Button je Tarif)
|
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
|
- **`abo-upgrade.php`** unterscheidet jetzt drei Fälle statt nur
|
||||||
„Checkout-Session erzeugen":
|
„Checkout-Session erzeugen":
|
||||||
1. Zielwert `free` mit bestehender bezahlter Subscription → echtes
|
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
|
2. Zielwert ein anderer bezahlter Tarif **und** bereits eine
|
||||||
aktive/`trialing`/`past_due`-Subscription vorhanden → Preis wird
|
aktive/`trialing`/`past_due`-Subscription vorhanden → Preis wird
|
||||||
in-place über `stripe_update_subscription_price()` gewechselt
|
in-place über `stripe_update_subscription_price()` gewechselt
|
||||||
(Stripe-Proration übernimmt die anteilige Verrechnung), kein neuer
|
(ohne Zwischenbelastung oder Gutschrift in der laufenden Periode), kein
|
||||||
Checkout-Redirect nötig. Lokal sofort gespiegelt, der Webhook
|
neuer Checkout-Redirect nötig. Lokal sofort gespiegelt, der Webhook
|
||||||
(`customer.subscription.updated`) bestätigt denselben Stand redundant.
|
(`customer.subscription.updated`) bestätigt denselben Stand redundant.
|
||||||
3. Kein aktives Abo bisher → wie vorher eine neue Stripe-Checkout-Session.
|
3. Kein aktives Abo bisher → wie vorher eine neue Stripe-Checkout-Session.
|
||||||
- **`app/stripe.php`** neu: `stripe_get_subscription()` (liest Status,
|
- **`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
|
`stripe_update_subscription_price()` (Preis am bestehenden
|
||||||
Subscription-Item austauschen, `cancel_at_period_end` explizit auf
|
Subscription-Item austauschen, `cancel_at_period_end` explizit auf
|
||||||
`false` – ein Tarifwechsel ist ein Signal, das Abo fortzuführen),
|
`false` – ein Tarifwechsel ist ein Signal, das Abo fortzuführen),
|
||||||
`stripe_cancel_subscription()` (sofortige Kündigung, nicht zum
|
`stripe_schedule_subscription_cancellation()` (ordentliche Kündigung zum
|
||||||
Periodenende).
|
Periodenende). `stripe_cancel_subscription()` bleibt dem wirksamen
|
||||||
|
Verbraucherwiderruf vorbehalten.
|
||||||
- **`app/billing.php`** neu: `billing_plan_selectable_for()` prüft, ob die
|
- **`app/billing.php`** neu: `billing_plan_selectable_for()` prüft, ob die
|
||||||
aktive Teilnehmerzahl das Limit eines Zieltarifs einhält – Basis für das
|
aktive Teilnehmerzahl das Limit eines Zieltarifs einhält – Basis für das
|
||||||
Deaktivieren nicht passender Tarife in der UI und für die
|
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
|
- `enterprise` bleibt bewusst kein Selbstbedienungs-Ziel (kein
|
||||||
Stripe-Preis vorhanden, Preis „auf Anfrage"); Auswahl verlinkt auf
|
Stripe-Preis vorhanden, Preis „auf Anfrage"); Auswahl verlinkt auf
|
||||||
manuellen Kontakt statt eine falsche Buchung zu versuchen.
|
manuellen Kontakt statt eine falsche Buchung zu versuchen.
|
||||||
- Bestätigungsdialog (JS `confirm()`) vor jedem Tarifwechsel, da ein
|
- Vor kostenpflichtiger Neubuchung oder Tarifwechsel zeigt
|
||||||
Wechsel bei Stripe sofort wirksam wird und anteilig verrechnet wird.
|
`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
|
### Noch offen
|
||||||
|
|
||||||
|
|||||||
+44
-6
@@ -1,6 +1,6 @@
|
|||||||
# Deployment und Go-Live
|
# Deployment und Go-Live
|
||||||
|
|
||||||
Stand: 2026-08-06
|
Stand: 2026-08-22
|
||||||
|
|
||||||
Dieses Dokument beschreibt, wie die Anwendung auf den Netcup-Webspace
|
Dieses Dokument beschreibt, wie die Anwendung auf den Netcup-Webspace
|
||||||
(Plesk) ausgerollt und in Betrieb genommen wird. Es ergänzt
|
(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`,
|
Zusätzlich sperrt die `.htaccess` `var|database|scripts|docs|app|lib`,
|
||||||
die Vendor-Verzeichnisse `TCPDF|PHPMailer|DataTables`, Dotfile-Ordner
|
die Vendor-Verzeichnisse `TCPDF|PHPMailer|DataTables`, Dotfile-Ordner
|
||||||
sowie Konfigurationsdateien und `.sql`/`.md`/`.jsonc`/`.sh`/`.log`-Dateien.
|
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
|
Sie erzwingt außerdem HTTPS — ohne Redirect käme ein Nutzer per `http` an
|
||||||
und bekäme eine Session-Cookie ohne `secure`-Flag.
|
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
|
### 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
|
über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur
|
||||||
fehlende Migrationen an.
|
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 |
|
| Zahlungserinnerungen | `/httpdocs/scripts/send-payment-reminders.php` | täglich, z. B. 07:00 |
|
||||||
| PayPal-Mailabruf | `/httpdocs/scripts/fetch-paypal-payments.php` | alle 15–30 Minuten |
|
| PayPal-Mailabruf | `/httpdocs/scripts/fetch-paypal-payments.php` | alle 15–30 Minuten |
|
||||||
|
| Technische Löschfristen | `/httpdocs/scripts/purge-expired-operational-data.php` | stündlich |
|
||||||
|
| Beendete Verträge löschen | `/httpdocs/scripts/purge-ended-contracts.php` | täglich |
|
||||||
| Datenbank-Backup | Shell-Befehl, siehe `docs/betrieb-backup-monitoring.md` | täglich nachts |
|
| Datenbank-Backup | Shell-Befehl, siehe `docs/betrieb-backup-monitoring.md` | täglich nachts |
|
||||||
|
|
||||||
Zum Backup-Task: Das Zielverzeichnis muss **außerhalb** von `httpdocs`
|
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,
|
Ort übertragen — ein Backup, das nur auf demselben Webspace liegt,
|
||||||
schützt nicht gegen den Ausfall des Anbieters.
|
schützt nicht gegen den Ausfall des Anbieters.
|
||||||
|
|
||||||
Vor der Scharfschaltung beide PHP-Tasks einmal manuell auslösen.
|
Vor der Scharfschaltung die Aufgaben mit Lösch- oder Buchungswirkung zuerst
|
||||||
`send-payment-reminders.php` unterstützt dafür `--dry-run`.
|
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
|
## 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
|
echte Zahlungseingang ist dessen erster Test. Danach in Dolibarr
|
||||||
kontrollieren, ob die Rechnung korrekt angelegt und validiert wurde.
|
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
|
## PayPal-Postfach
|
||||||
|
|
||||||
Für die automatische Verbuchung weitergeleiteter PayPal-Zahlungsmails
|
Für die automatische Verbuchung weitergeleiteter PayPal-Zahlungsmails
|
||||||
@@ -299,6 +311,16 @@ frisches Backup ziehen.
|
|||||||
|
|
||||||
Vor der Umstellung auf `app.kaffeeliste.de`:
|
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
|
- [ ] Kompletter Funktionsdurchlauf auf Staging erfolgreich
|
||||||
(Registrierung, Mailversand, Mitglieder, Striche, Einzahlungen,
|
(Registrierung, Mailversand, Mitglieder, Striche, Einzahlungen,
|
||||||
CSV-Import, PDF-Export, Jahresauswertung, Stripe im Testmodus)
|
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
|
- [ ] Backup-Task läuft und ein Restore wurde einmal testweise
|
||||||
eingespielt (`docs/betrieb-backup-monitoring.md`)
|
eingespielt (`docs/betrieb-backup-monitoring.md`)
|
||||||
- [ ] Stripe-Live-Keys und Produktiv-Webhook eingetragen
|
- [ ] Stripe-Live-Keys und Produktiv-Webhook eingetragen
|
||||||
- [ ] Impressum, AGB und Datenschutzerklärung inhaltlich freigegeben
|
- [ ] `LEGAL_PHONE`, `LEGAL_EMAIL` und `LEGAL_TICKET_URL` zeigen auf
|
||||||
- [ ] Auftragsverarbeitungsvertrag (AVV) für Kunden vorbereitet
|
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
|
- [ ] Frische Produktivdatenbank ohne Testdaten
|
||||||
|
|
||||||
## Bewusst offen
|
## Bewusst offen
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ $result = $token !== ''
|
|||||||
<a href="impressum.php">Impressum</a>
|
<a href="impressum.php">Impressum</a>
|
||||||
<a href="agb.php">AGB</a>
|
<a href="agb.php">AGB</a>
|
||||||
<a href="datenschutz.php">Datenschutz</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 href="preise.php">Preise</a>
|
||||||
|
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ putenv('APP_TIMEZONE=Europe/Berlin');
|
|||||||
// Dashboard/Login/etc.; jeder andere Host (z. B. kaffeeliste.de) zeigt
|
// Dashboard/Login/etc.; jeder andere Host (z. B. kaffeeliste.de) zeigt
|
||||||
// stattdessen die Landingpage. Siehe index.php.
|
// stattdessen die Landingpage. Siehe index.php.
|
||||||
putenv('APP_HOST=app.kaffeeliste.de');
|
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).
|
// Ziel des Wasserzeichens im kostenlosen Tarif (unten rechts in der App).
|
||||||
// Ohne Angabe wird die Marketing-Domain aus APP_HOST abgeleitet, indem die
|
// 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=noreply@kaffeeliste.de');
|
||||||
putenv('APP_MAIL_FROM_NAME=Kaffeeliste');
|
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_SECRET_KEY=');
|
||||||
putenv('STRIPE_PUBLISHABLE_KEY=');
|
putenv('STRIPE_PUBLISHABLE_KEY=');
|
||||||
putenv('STRIPE_WEBHOOK_SECRET=');
|
putenv('STRIPE_WEBHOOK_SECRET=');
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ if ($saasNavUser !== null) {
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|
||||||
<footer id="footer">
|
<footer id="footer">
|
||||||
<p class="copyright">© Copyright: Clemens Creutzburg All rights reserved. Design: <a href="http://html5up.net">HTML5 UP</a> · <a href="impressum.php">Impressum</a> · <a href="agb.php">AGB</a> · <a href="datenschutz.php">Datenschutz</a> · <a href="preise.php">Preise</a></p>
|
<p class="copyright">© 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>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
+14
-8
@@ -1,4 +1,4 @@
|
|||||||
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
|
<?php require_once __DIR__ . '/app/legal.php'; ?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
@@ -32,11 +32,22 @@
|
|||||||
<p>Geschäftsbezeichnung: CTB-IT</p>
|
<p>Geschäftsbezeichnung: CTB-IT</p>
|
||||||
|
|
||||||
<h2>Kontakt</h2>
|
<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>
|
<h2>Umsatzsteuer</h2>
|
||||||
<p>Aufgrund der Kleinunternehmerregelung gemäß § 19 UStG sind die Umsätze von der Umsatzsteuer befreit.</p>
|
<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>
|
<h2>Haftung für Links</h2>
|
||||||
<p>Diese App enthält gegebenenfalls Links zu externen Websites Dritter, auf
|
<p>Diese App enthält gegebenenfalls Links zu externen Websites Dritter, auf
|
||||||
@@ -49,12 +60,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="public-footer-links">
|
<?php echo app_public_legal_footer(); ?>
|
||||||
<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>
|
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
<script src="assets/js/browser.min.js"></script>
|
<script src="assets/js/browser.min.js"></script>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ require_once __DIR__ . '/app/features.php';
|
|||||||
|
|
||||||
$user = saas_require_login();
|
$user = saas_require_login();
|
||||||
$devVerificationToken = null;
|
$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'])) {
|
if (saas_should_show_auth_links() && !empty($_SESSION['saas_dev_email_verification_token'])) {
|
||||||
$devVerificationToken = (string)$_SESSION['saas_dev_email_verification_token'];
|
$devVerificationToken = (string)$_SESSION['saas_dev_email_verification_token'];
|
||||||
unset($_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>
|
<div class="hint-box success"><p>Das Kundenkonto wurde angelegt.</p></div>
|
||||||
<?php endif; ?>
|
<?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): ?>
|
<?php if ($devVerificationToken !== null): ?>
|
||||||
<div class="hint-box success">
|
<div class="hint-box success">
|
||||||
<p>Der Verifizierungslink wurde vorbereitet.</p>
|
<p>Der Verifizierungslink wurde vorbereitet.</p>
|
||||||
|
|||||||
+154
@@ -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']); ?>&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>
|
||||||
@@ -283,7 +283,11 @@ $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
|||||||
<a href="impressum.php">Impressum</a>
|
<a href="impressum.php">Impressum</a>
|
||||||
<a href="agb.php">AGB</a>
|
<a href="agb.php">AGB</a>
|
||||||
<a href="datenschutz.php">Datenschutz</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 href="preise.php">Preise</a>
|
||||||
|
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
## 1. Anbieter, Geltungsbereich und Begriffe
|
||||||
|
|
||||||
|
Diese Allgemeinen Geschäftsbedingungen gelten für die Nutzung der webbasierten Anwendung „Kaffeeliste“ durch Unternehmer im Sinne des § 14 BGB und durch Verbraucher im Sinne des § 13 BGB.
|
||||||
|
|
||||||
|
Anbieter ist Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de. Weitere Kontaktmöglichkeiten stehen im [Impressum](impressum.php).
|
||||||
|
|
||||||
|
„Kunde“ ist die Person, die einen Kundenbereich (Mandanten) registriert. „Teilnehmer“ sind Personen, deren Kaffee- oder Getränkebuchungen der Kunde innerhalb dieses Bereichs verwaltet.
|
||||||
|
|
||||||
|
## 2. Vertragsschluss und Vertragstext
|
||||||
|
|
||||||
|
Der kostenlose Nutzungsvertrag kommt zustande, wenn der Kunde das Registrierungsformular vollständig absendet, diese AGB akzeptiert und der Anbieter den Kundenbereich freischaltet. Der Kunde kann Eingabefehler vor dem Absenden über die üblichen Formularfunktionen berichtigen. Vertragssprache ist Deutsch.
|
||||||
|
|
||||||
|
Ein kostenpflichtiges Abonnement kommt erst zustande, wenn der Kunde nach Anzeige des Tarifs, des monatlichen Gesamtpreises, der Laufzeit und der Kündigungsbedingungen den Bestellvorgang beim Zahlungsdienstleister Stripe mit einer eindeutig auf die Zahlungspflicht hinweisenden Schaltfläche abschließt und die Buchung bestätigt wird. Bis dahin kann der Vorgang abgebrochen oder berichtigt werden.
|
||||||
|
|
||||||
|
Der Anbieter protokolliert die angenommenen Versionen der Rechtstexte und übersendet dem Kunden eine Vertragsbestätigung in Textform. Der Kunde kann die aktuellen Texte außerdem jederzeit herunterladen oder ausdrucken.
|
||||||
|
|
||||||
|
## 3. Leistungen und technische Voraussetzungen
|
||||||
|
|
||||||
|
Der Anbieter stellt eine mandantenfähige Webanwendung zur Verwaltung einer internen Kaffee- oder Getränkekasse bereit. Zum Kern gehören die Verwaltung von Teilnehmern und Rollen, die Erfassung von Verbrauch und Einzahlungen, die Berechnung von Salden sowie die im gebuchten Tarif ausgewiesenen Export- und Verwaltungsfunktionen.
|
||||||
|
|
||||||
|
Erforderlich sind ein aktueller Browser, eine Internetverbindung und eine erreichbare E-Mail-Adresse. Die Anwendung ist für die Nutzung im Browser vorgesehen. Ein Anspruch auf eine mobile App, bestimmte Schnittstellen oder Funktionen außerhalb der veröffentlichten Leistungsbeschreibung besteht nicht.
|
||||||
|
|
||||||
|
Der Anbieter darf die Anwendung aktualisieren und sicherheitsbedingt ändern. Bei Verbraucherverträgen über digitale Produkte bleiben die gesetzlichen Aktualisierungs-, Bereitstellungs- und Mängelrechte, insbesondere nach §§ 327 ff. BGB, unberührt. Wesentliche, vertraglich geschuldete Funktionen werden nicht ohne sachlichen Grund entzogen.
|
||||||
|
|
||||||
|
## 4. Tarife, Preise und Zahlung
|
||||||
|
|
||||||
|
Tarifgrenzen und monatliche Endpreise stehen auf der Seite [Preise](preise.php). Wegen Anwendung der Kleinunternehmerregelung nach § 19 UStG wird keine Umsatzsteuer ausgewiesen. Es fallen keine Versandkosten an.
|
||||||
|
|
||||||
|
Kostenpflichtige Tarife werden monatlich im Voraus über Stripe abgerechnet. Eine automatische Hochstufung allein wegen einer höheren Teilnehmerzahl findet nicht statt. Ist die Grenze des gebuchten Tarifs erreicht, können keine weiteren aktiven Teilnehmer angelegt oder reaktiviert werden, bis der Kunde selbst einen passenden Tarif bucht oder Teilnehmer deaktiviert.
|
||||||
|
|
||||||
|
Ein Wechsel zwischen kostenpflichtigen Tarifen schaltet die neue Teilnehmergrenze sofort frei. Für die bereits laufende, bezahlte Abrechnungsperiode erfolgt weder eine zusätzliche zeitanteilige Belastung noch eine Gutschrift; der neue volle Monatspreis gilt ab der nächsten Verlängerung. Ein Wechsel in den kostenlosen Tarif ist eine Kündigung des kostenpflichtigen Abonnements zum Ende des laufenden Abrechnungszeitraums.
|
||||||
|
|
||||||
|
Gerät der Kunde in Zahlungsverzug, darf der Anbieter kostenpflichtige Zusatzfunktionen nach vorherigem Hinweis vorübergehend sperren. Gesetzliche Rechte bleiben unberührt.
|
||||||
|
|
||||||
|
## 5. Pflichten des Kunden
|
||||||
|
|
||||||
|
Der Kunde macht richtige Angaben, hält seine E-Mail-Adresse aktuell, schützt Zugangsdaten und informiert den Anbieter unverzüglich über vermuteten Missbrauch. Inhalte und Daten dürfen nicht rechtswidrig sein, Rechte Dritter verletzen, Schadsoftware enthalten oder den Dienst übermäßig belasten.
|
||||||
|
|
||||||
|
Wer Daten anderer Personen verwaltet, stellt eine hierfür erforderliche Rechtsgrundlage und Information der Betroffenen sicher. Soweit der Kunde Verantwortlicher im Sinne der DSGVO ist und der Anbieter in seinem Auftrag verarbeitet, gilt der elektronisch vereinbarte [Auftragsverarbeitungsvertrag](avv.php). Für ausschließlich persönliche oder familiäre Tätigkeiten kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten.
|
||||||
|
|
||||||
|
## 6. Verfügbarkeit, Wartung und Support
|
||||||
|
|
||||||
|
Der Anbieter betreibt den Dienst mit der im Verkehr üblichen Sorgfalt. Ohne gesonderte Service-Level-Vereinbarung wird keine bestimmte prozentuale Verfügbarkeit garantiert. Vorhersehbare Wartungen werden möglichst zu nutzungsarmen Zeiten vorgenommen. Kurzzeitige Einschränkungen durch Wartung, Sicherheitsmaßnahmen oder Ereignisse außerhalb des Einflussbereichs des Anbieters sind möglich.
|
||||||
|
|
||||||
|
Supportanfragen können per E-Mail oder über das im Impressum verlinkte Ticketsystem gestellt werden. Gesetzliche Mängelrechte werden dadurch nicht eingeschränkt.
|
||||||
|
|
||||||
|
## 7. Laufzeit und Kündigung
|
||||||
|
|
||||||
|
Der kostenlose Vertrag läuft auf unbestimmte Zeit und kann jederzeit ohne Frist gekündigt werden. Kostenpflichtige Abonnements laufen ebenfalls auf unbestimmte Zeit, haben eine monatliche Abrechnungsperiode und können jederzeit zum Ende der laufenden Abrechnungsperiode gekündigt werden. Es gibt keine darüber hinausgehende Mindestlaufzeit.
|
||||||
|
|
||||||
|
Verbraucher können die ständig erreichbare Funktion [Verträge hier kündigen](kuendigen.php) verwenden. Eine Kündigung in Textform per E-Mail ist ebenfalls möglich. Das Recht zur außerordentlichen Kündigung aus wichtigem Grund bleibt bestehen. Der Anbieter kann ordentlich mit einer Frist von einem Monat zum Ende der laufenden Abrechnungsperiode kündigen; bei kostenlosen Verträgen mit einer Frist von einem Monat.
|
||||||
|
|
||||||
|
Der Kunde sollte seinen Datenexport vor dem Vertragsende herunterladen. Nach Vertragsende wird der Zugang gesperrt; die operativen Mandantendaten werden 30 Tage später gelöscht. Eine vom Kunden im Konto ausdrücklich ausgelöste Mandantenlöschung wirkt sofort und ist nicht rückgängig zu machen. Abrechnungsunterlagen, Vertragsnachweise und offene Rechtsfälle werden nur im gesetzlich erforderlichen Umfang getrennt weiter aufbewahrt.
|
||||||
|
|
||||||
|
## 8. Widerrufsrecht für Verbraucher
|
||||||
|
|
||||||
|
Verbrauchern steht bei Fernabsatzverträgen grundsätzlich ein vierzehntägiges Widerrufsrecht zu. Einzelheiten, Musterformular und die elektronische Widerrufsfunktion stehen unter [Widerruf](widerruf.php). Verlangt ein Verbraucher ausdrücklich, dass eine kostenpflichtige Leistung vor Ablauf der Widerrufsfrist beginnt, kann er bei einem Widerruf Wertersatz für die bis dahin erbrachte Leistung schulden. Das Widerrufsrecht erlischt bei einer laufenden Dienstleistung nicht bereits durch den bloßen Leistungsbeginn.
|
||||||
|
|
||||||
|
## 9. Mängelrechte
|
||||||
|
|
||||||
|
Es gelten die gesetzlichen Mängelrechte. Verbraucher haben bei digitalen Produkten insbesondere Anspruch auf vertragsgemäße Bereitstellung und erforderliche Aktualisierungen sowie bei Mängeln die gesetzlichen Rechte auf Nacherfüllung, Vertragsbeendigung, Minderung und gegebenenfalls Schadensersatz. Garantien werden nur übernommen, wenn sie ausdrücklich als solche bezeichnet sind.
|
||||||
|
|
||||||
|
## 10. Haftung
|
||||||
|
|
||||||
|
Der Anbieter haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, nach dem Produkthaftungsgesetz, bei arglistigem Verschweigen eines Mangels und im Umfang einer ausdrücklich übernommenen Garantie.
|
||||||
|
|
||||||
|
Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht ist die Haftung auf den bei Vertragsschluss typischerweise vorhersehbaren Schaden begrenzt. Wesentliche Vertragspflichten sind solche, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen, soweit dies gesetzlich zulässig ist.
|
||||||
|
|
||||||
|
Die vorstehenden Begrenzungen gelten entsprechend zugunsten der Beschäftigten und Erfüllungsgehilfen des Anbieters. Zwingende Verbraucherrechte und Ansprüche nach den Vorschriften über digitale Produkte bleiben unberührt.
|
||||||
|
|
||||||
|
## 11. Änderungen dieser Bedingungen und der Leistung
|
||||||
|
|
||||||
|
Änderungen dieser AGB für bestehende Verträge erfolgen nur aus einem sachlichen Grund, insbesondere wegen einer Rechtsänderung, Sicherheitsanforderung oder einer notwendigen technischen Weiterentwicklung, und nur soweit das Verhältnis von Leistung und Gegenleistung nicht unangemessen zulasten des Kunden verschoben wird. Der Anbieter teilt Änderungen mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit.
|
||||||
|
|
||||||
|
Eine Änderung, die Hauptleistung, Preis oder wesentliche Verbraucherrechte betrifft, wird nur mit ausdrücklicher Zustimmung wirksam. Stimmt der Kunde einer notwendigen Änderung nicht zu, dürfen beide Seiten den Vertrag nach den bisherigen Bedingungen ordentlich beenden.
|
||||||
|
|
||||||
|
## 12. Verbraucherstreitbeilegung
|
||||||
|
|
||||||
|
Der Anbieter ist nicht verpflichtet und derzeit nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen. Unabhängig davon können Beschwerden über die Kontaktwege im Impressum eingereicht werden.
|
||||||
|
|
||||||
|
## 13. Schlussbestimmungen
|
||||||
|
|
||||||
|
Es gilt deutsches Recht. Bei Verbrauchern gilt diese Rechtswahl nur, soweit dadurch zwingender Schutz des Staates ihres gewöhnlichen Aufenthalts nicht entzogen wird. Das UN-Kaufrecht ist ausgeschlossen.
|
||||||
|
|
||||||
|
Ausschließlicher Gerichtsstand Hannover wird nur vereinbart, wenn der Kunde Kaufmann, juristische Person des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen ist oder keinen allgemeinen Gerichtsstand in Deutschland hat und die Vereinbarung gesetzlich zulässig ist. Im Übrigen gelten die gesetzlichen Gerichtsstände.
|
||||||
|
|
||||||
|
Sollte eine Regelung unwirksam sein, gelten an ihrer Stelle die gesetzlichen Vorschriften; der übrige Vertrag bleibt bestehen.
|
||||||
|
|
||||||
|
Stand: 22. August 2026
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
## Vertrag über Auftragsverarbeitung nach Art. 28 DSGVO
|
||||||
|
|
||||||
|
zwischen dem im Kundenkonto bezeichneten Kunden („Verantwortlicher“) und Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover („Auftragsverarbeiter“).
|
||||||
|
|
||||||
|
Diese Vereinbarung gilt, soweit der Kunde personenbezogene Daten anderer Personen in der Kaffeeliste in eigener Verantwortlichkeit verarbeitet. Bei einer ausschließlich persönlichen oder familiären Tätigkeit kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten; die nachfolgenden Schutz- und Vertraulichkeitspflichten gelten vertraglich dennoch.
|
||||||
|
|
||||||
|
## 1. Gegenstand, Dauer, Art und Zweck
|
||||||
|
|
||||||
|
Gegenstand ist der Betrieb der webbasierten Kaffeeliste nach dem Hauptvertrag. Die Verarbeitung beginnt mit Anlage von Daten anderer Personen und dauert bis zu deren Löschung oder dem Ende des Hauptvertrags. Sie umfasst Erheben, Erfassen, Ordnen, Speichern, Auslesen, Abfragen, Berechnen, Übermitteln an vom Kunden bestimmte Empfänger, Einschränken, Anonymisieren und Löschen.
|
||||||
|
|
||||||
|
Zweck ist die Verwaltung einer internen Kaffee- oder Getränkekasse, insbesondere von Teilnehmern, Verbrauchsbuchungen, Einzahlungen, Salden, Benachrichtigungen und Exporten.
|
||||||
|
|
||||||
|
## 2. Datenarten und betroffene Personen
|
||||||
|
|
||||||
|
- Stammdaten: Name, Anzeigename, optional E-Mail-Adresse und PayPal-Anzeigename
|
||||||
|
- Nutzungs- und Buchungsdaten: Verbrauch, Einzahlungen, Salden, Zeitpunkte, Bemerkungen und Zuordnungen
|
||||||
|
- Kommunikationsdaten: Empfänger, Betreff, Versandstatus und Fehlermeldungen von Benachrichtigungen
|
||||||
|
- Zugangs- und Protokolldaten: Rollen, technische Kennungen und sicherheitsrelevante Aktionen
|
||||||
|
- Betroffene: Teilnehmer, Beschäftigte, Vereinsmitglieder, Familien- oder Gruppenmitglieder sowie vom Kunden berechtigte Nutzer
|
||||||
|
|
||||||
|
Besondere Kategorien personenbezogener Daten nach Art. 9 DSGVO sind nicht vorgesehen. Der Kunde weist den Auftragsverarbeiter nicht an, solche Daten einzugeben.
|
||||||
|
|
||||||
|
## 3. Weisungen
|
||||||
|
|
||||||
|
Der Auftragsverarbeiter verarbeitet die Daten nur auf dokumentierte Weisung des Verantwortlichen, soweit keine gesetzliche Verpflichtung eine andere Verarbeitung verlangt. Der Hauptvertrag, die Einstellungen und die Nutzung der vorgesehenen Funktionen gelten als dokumentierte Weisungen. Weitergehende Weisungen können in Textform über die Kontaktwege im Impressum erteilt werden.
|
||||||
|
|
||||||
|
Hält der Auftragsverarbeiter eine Weisung für datenschutzwidrig, informiert er den Verantwortlichen unverzüglich und darf die Ausführung bis zur Klärung aussetzen. Zusätzlicher Aufwand außerhalb des vereinbarten Leistungsumfangs kann nach vorheriger Vereinbarung berechnet werden.
|
||||||
|
|
||||||
|
## 4. Vertraulichkeit und Personal
|
||||||
|
|
||||||
|
Der Auftragsverarbeiter setzt nur Personen ein, die zur Vertraulichkeit verpflichtet und angemessen über Datenschutz und Informationssicherheit unterwiesen wurden. Zugriffe werden auf das zur Aufgabenerfüllung erforderliche Maß beschränkt.
|
||||||
|
|
||||||
|
## 5. Technische und organisatorische Maßnahmen
|
||||||
|
|
||||||
|
- verschlüsselte Übertragung über TLS und HSTS auf der Produktivdomain
|
||||||
|
- Passwörter nur als zeitgemäße, gesalzene Hashwerte; geheime Schlüssel außerhalb des Quellcodes
|
||||||
|
- rollen- und mandantenbezogene serverseitige Zugriffskontrollen nach dem Minimalprinzip
|
||||||
|
- Sitzungs-Cookies mit HttpOnly, Secure bei HTTPS und SameSite; CSRF-Schutz bei schreibenden Vorgängen
|
||||||
|
- Begrenzung automatisierter Anmelde- und Rücksetzversuche sowie Protokollierung sicherheitsrelevanter Administrationsvorgänge
|
||||||
|
- getrennte Mandantenkennungen, Fremdschlüssel und automatisierte Isolationstests
|
||||||
|
- regelmäßige Sicherheitsaktualisierungen, eingeschränkte Administrationszugänge und gesicherte Betriebszugänge
|
||||||
|
- regelmäßige verschlüsselte oder zugriffsgeschützte Datensicherungen nach dem dokumentierten Backupkonzept sowie Wiederherstellungstests
|
||||||
|
- Lösch-, Export- und Bereinigungskonzepte für Mandantendaten, Protokolle und temporäre Daten
|
||||||
|
- Verfahren zur Behandlung von Sicherheitsvorfällen und zur Bewertung meldepflichtiger Datenschutzverletzungen
|
||||||
|
|
||||||
|
Der Verantwortliche prüft diese Maßnahmen im Hinblick auf sein Risiko und informiert den Auftragsverarbeiter vor Vertragsbeginn, wenn ergänzende Maßnahmen erforderlich sind. Änderungen bleiben zulässig, sofern das Schutzniveau nicht unterschritten wird.
|
||||||
|
|
||||||
|
## 6. Unterauftragsverarbeiter
|
||||||
|
|
||||||
|
Der Verantwortliche erteilt die allgemeine Genehmigung zum Einsatz folgender Unterauftragsverarbeiter:
|
||||||
|
|
||||||
|
- netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland – Rechenzentrum, Hosting, Speicher, Backup-Infrastruktur und Mailtransport; Verarbeitungsort Deutschland beziehungsweise Europäische Union
|
||||||
|
|
||||||
|
Stripe und das interne Abrechnungssystem Dolibarr erhalten grundsätzlich nur Vertrags-, Rechnungs- und Zahlungsdaten des Kunden, nicht die im Auftrag verwalteten Teilnehmer- und Kasseninhalte. Soweit im Einzelfall doch Auftragsdaten an einen weiteren Dienst übermittelt werden sollen, wird dieser vorab als Unterauftragsverarbeiter einbezogen.
|
||||||
|
|
||||||
|
Über beabsichtigte Änderungen der Unterauftragsverarbeiter informiert der Auftragsverarbeiter mindestens 30 Tage vorher in Textform. Der Verantwortliche kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Ist keine zumutbare Alternative möglich, kann jede Partei den betroffenen Dienst zum Änderungszeitpunkt kündigen.
|
||||||
|
|
||||||
|
Der Auftragsverarbeiter verpflichtet Unterauftragsverarbeiter nach Art. 28 Abs. 4 DSGVO mindestens zu gleichwertigen Pflichten und bleibt gegenüber dem Verantwortlichen für deren Erfüllung verantwortlich.
|
||||||
|
|
||||||
|
## 7. Unterstützung des Verantwortlichen
|
||||||
|
|
||||||
|
Der Auftragsverarbeiter unterstützt den Verantwortlichen unter Berücksichtigung der Art der Verarbeitung angemessen bei Betroffenenanfragen, Datenschutz-Folgenabschätzungen, Konsultationen mit Aufsichtsbehörden und bei der Erfüllung der Pflichten aus Art. 32 bis 36 DSGVO. Geht eine Betroffenenanfrage unmittelbar ein, leitet er sie ohne eigene inhaltliche Entscheidung an den Verantwortlichen weiter, soweit der Anbieter nicht für eigene Datenverarbeitungen selbst verantwortlich ist.
|
||||||
|
|
||||||
|
## 8. Datenschutzverletzungen
|
||||||
|
|
||||||
|
Der Auftragsverarbeiter informiert den Verantwortlichen unverzüglich, nachdem ihm eine Verletzung des Schutzes von Auftragsdaten bekannt wurde. Die Meldung enthält, soweit verfügbar, Art und Umfang des Vorfalls, betroffene Daten und Personen, wahrscheinliche Folgen, ergriffene oder vorgeschlagene Maßnahmen sowie einen Kontaktpunkt. Fehlende Angaben werden nachgereicht.
|
||||||
|
|
||||||
|
## 9. Nachweise und Kontrollen
|
||||||
|
|
||||||
|
Der Auftragsverarbeiter stellt die zur Darlegung der Einhaltung von Art. 28 DSGVO erforderlichen Informationen bereit. Der Verantwortliche darf nach angemessener Vorankündigung und unter Wahrung der Sicherheit und Vertraulichkeit selbst oder durch einen zur Verschwiegenheit verpflichteten Prüfer kontrollieren. Vorrangig werden aktuelle Dokumentationen und geeignete Nachweise genutzt. Vor-Ort-Prüfungen erfolgen bei berechtigtem Anlass und während üblicher Geschäftszeiten; vermeidbarer Zusatzaufwand kann nach vorheriger Vereinbarung berechnet werden. Rechte der Aufsichtsbehörden bleiben unberührt.
|
||||||
|
|
||||||
|
## 10. Rückgabe und Löschung
|
||||||
|
|
||||||
|
Der Verantwortliche kann die Auftragsdaten vor Vertragsende über die Exportfunktion abrufen. Nach Vertragsende sperrt der Auftragsverarbeiter den Zugang und löscht die operativen Auftragsdaten spätestens nach 30 Tagen, sofern keine gesetzliche Aufbewahrungspflicht entgegensteht. Eine ausdrücklich ausgelöste Mandantenlöschung wirkt sofort. In turnusmäßig überschriebenen Sicherungen können Daten danach noch bis zu 14 Tage enthalten; sie werden nicht erneut produktiv verwendet und bei einer Wiederherstellung erneut dem Löschlauf unterzogen.
|
||||||
|
|
||||||
|
Abrechnungs- und Vertragsdaten, die der Anbieter in eigener Verantwortlichkeit aufbewahren muss, sind nicht Gegenstand der Löschung nach diesem Abschnitt.
|
||||||
|
|
||||||
|
## 11. Verantwortungsbereiche und Schlussbestimmungen
|
||||||
|
|
||||||
|
Der Verantwortliche ist für Zulässigkeit, Transparenz, Datenrichtigkeit, Rechtsgrundlagen, Betroffenenrechte und seine Nutzerberechtigungen verantwortlich. Der Auftragsverarbeiter ist für die vertragsgemäße, sichere Verarbeitung im Auftrag verantwortlich.
|
||||||
|
|
||||||
|
Im Übrigen gelten Hauptvertrag und AGB. Bei Widersprüchen zu Datenschutzpflichten geht dieser AVV vor. Änderungen bedürfen der Textform. Die elektronische Annahme wird mit Version, Zeit, Nutzer und technischen Nachweisdaten protokolliert.
|
||||||
|
|
||||||
|
Stand: 22. August 2026
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
## 1. Verantwortlicher und Kontakt
|
||||||
|
|
||||||
|
Verantwortlich für die eigenen Verarbeitungen des Kaffeeliste-Dienstes ist Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de. Telefonnummer und Ticketsystem stehen im [Impressum](impressum.php). Ein Datenschutzbeauftragter ist derzeit nicht bestellt, weil keine gesetzliche Bestellpflicht festgestellt wurde.
|
||||||
|
|
||||||
|
## 2. Unsere Rollen
|
||||||
|
|
||||||
|
Für Registrierung, Kundenkonto, Vertragsabwicklung, Abrechnung, Support, Sicherheit und den Betrieb der öffentlichen Website ist CTB-IT Verantwortlicher.
|
||||||
|
|
||||||
|
Verwaltet ein Kunde Daten anderer Personen innerhalb seines Mandanten, ist grundsätzlich der Kunde Verantwortlicher und CTB-IT Auftragsverarbeiter. Dies regelt der [AVV](avv.php). Bei ausschließlich persönlichen oder familiären Tätigkeiten eines privaten Kunden kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten. CTB-IT schützt die Daten technisch und vertraglich unabhängig davon.
|
||||||
|
|
||||||
|
## 3. Website, Serverprotokolle und Sicherheit
|
||||||
|
|
||||||
|
Beim Aufruf werden technisch erforderliche Verbindungsdaten verarbeitet: IP-Adresse, Datum und Uhrzeit, angeforderte Adresse, übertragene Datenmenge, Referrer, Browser, Betriebssystem und HTTP-Status. Zweck sind sichere Auslieferung, Fehleranalyse und Abwehr von Angriffen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; das berechtigte Interesse ist ein sicherer und stabiler Dienst.
|
||||||
|
|
||||||
|
Anwendungsseitige IP-Daten aus der Begrenzung automatisierter Versuche werden spätestens nach 24 Stunden gelöscht. Webserver-Protokolle sollen im Produktivbetrieb nach spätestens sieben Tagen gelöscht oder anonymisiert werden, sofern ein Sicherheitsvorfall keine längere, zweckgebundene Sicherung erfordert.
|
||||||
|
|
||||||
|
## 4. Registrierung und Kundenkonto
|
||||||
|
|
||||||
|
Wir verarbeiten Kundenname und -kürzel, Kundentyp, Name, E-Mail-Adresse, Passwort-Hash, E-Mail-Bestätigung, Rollen, Anmeldezeitpunkte, Vertrags- und Rechtstextversionen, Zeitpunkt der Annahme, IP-Adresse und User-Agent. Zwecke sind Vertragsschluss, Authentifizierung, Berechtigungen, Vertragsnachweis und Kommunikation. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b DSGVO sowie für Sicherheits- und Nachweisdaten Art. 6 Abs. 1 lit. f DSGVO. Pflichtfelder sind für Vertragsschluss und sichere Kontoführung erforderlich; ohne sie ist keine Registrierung möglich.
|
||||||
|
|
||||||
|
Ein Klartextpasswort wird nicht gespeichert. Die Kenntnisnahme der Datenschutzerklärung ist keine Einwilligung in Werbung oder Tracking.
|
||||||
|
|
||||||
|
## 5. Mandanten-, Teilnehmer- und Kassendaten
|
||||||
|
|
||||||
|
Je nach Nutzung werden Einstellungen, Marken- und Zahlungsangaben des Kunden, Teilnehmername, E-Mail-Adresse, PayPal-Anzeigename und -E-Mail, Nutzerzuordnung, Rollen, Aktivstatus, Verbrauch, Einzahlungen, Salden, Bemerkungen, Hinweise, FAQ-Inhalte, Importe, Benachrichtigungsstatus und Administrationsprotokolle verarbeitet.
|
||||||
|
|
||||||
|
Bei Auftragsverarbeitung bestimmt der Kunde Zwecke, Rechtsgrundlagen, Inhalte und Empfänger. Die Daten stammen vom Kunden, seinen Administratoren, den Teilnehmern oder aus vom Kunden veranlassten Importen. Teilnehmer sollten sich für Auskunft, Berichtigung oder Löschung zuerst an den jeweiligen Kunden wenden; CTB-IT unterstützt diesen nach dem AVV.
|
||||||
|
|
||||||
|
## 6. Vertrag, Abrechnung, Stripe und Dolibarr
|
||||||
|
|
||||||
|
Bei kostenpflichtigen Tarifen verarbeiten wir Tarif, Teilnehmerzahl, Abrechnungsstatus, Stripe-Kunden- und Abonnementkennung, Laufzeit, Rechnungsbetrag und Zahlungsstatus zur Vertragsdurchführung nach Art. 6 Abs. 1 lit. b DSGVO und für Buchführung und Steuerrecht nach Art. 6 Abs. 1 lit. c DSGVO.
|
||||||
|
|
||||||
|
Zahlungen werden durch Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, Irland, abgewickelt. Stripe erhält insbesondere Kontakt-, Vertrags-, Geräte- und Zahlungsdaten. Stripe verarbeitet einen Teil als unser Auftragsverarbeiter und einen Teil in eigener Verantwortlichkeit, etwa zur Betrugsprävention und Erfüllung finanzrechtlicher Pflichten. Übermittlungen in die USA oder andere Drittländer können auf Angemessenheitsbeschlüssen, insbesondere dem EU-US Data Privacy Framework für zertifizierte Empfänger, oder EU-Standardvertragsklauseln beruhen. Weitere Informationen: [Stripe-Datenschutz](https://stripe.com/de/privacy).
|
||||||
|
|
||||||
|
Bezahlte Rechnungen werden im selbst betriebenen Dolibarr-System unter verwaltung.ctb-it.de für Buchführung und Kundenverwaltung gespiegelt. Dabei werden Kunden-, Vertrags-, Rechnungs- und Zahlungsdaten verarbeitet, aber grundsätzlich keine Teilnehmer- oder Kasseninhalte.
|
||||||
|
|
||||||
|
## 7. PayPal-Zahlungsmails
|
||||||
|
|
||||||
|
Aktiviert ein Kunde die PayPal-Zuordnung, werden an die mandantenspezifische Zahlungsadresse weitergeleitete PayPal-Benachrichtigungen automatisiert ausgelesen. Verarbeitet werden unter anderem Absender- und Empfängeradresse, PayPal-Transaktionskennung, Name und E-Mail des Zahlers, Betrag, Währung, Zahlungszeit und Zuordnungsstatus. Zweck ist die vom Kunden gewünschte Zuordnung zu Teilnehmern und Einzahlungen. Bei Teilnehmerdaten erfolgt dies im Auftrag des Kunden; für die Bereitstellung und Sicherheit der Funktion zusätzlich auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||||
|
|
||||||
|
PayPal selbst verarbeitet Daten in eigener Verantwortlichkeit nach den dortigen Datenschutzinformationen. Die Kaffeeliste ruft keine PayPal-Kontodaten des Kunden über eine PayPal-API ab.
|
||||||
|
|
||||||
|
## 8. E-Mail, Support und Ticketsystem
|
||||||
|
|
||||||
|
Wir senden notwendige E-Mails zur Verifizierung, Anmeldung, Passwortzurücksetzung, Einladung, Vertragsbestätigung, Kündigung, Widerruf und zu vom Kunden aktivierten Kassenbenachrichtigungen. Verarbeitet werden Empfänger, Betreff, Status, Zeitpunkt und gegebenenfalls Fehler. Grundlage sind Art. 6 Abs. 1 lit. b DSGVO beziehungsweise bei Teilnehmernachrichten der Auftrag des Kunden. Versandprotokolle werden regelmäßig nach 180 Tagen gelöscht, soweit sie nicht als Vertragsnachweis länger erforderlich sind.
|
||||||
|
|
||||||
|
Bei Kontakt per E-Mail oder über ticketsystem.ctb-it.de verarbeiten wir die übermittelten Kontaktdaten, Inhalte, Anhänge, Zeitpunkte und technischen Protokolldaten, um die Anfrage zu bearbeiten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bei vorvertraglichen oder vertraglichen Anliegen, sonst Art. 6 Abs. 1 lit. f DSGVO. Unser Interesse ist nachvollziehbarer und sicherer Support. Bitte übermitteln Sie im Ticket keine unnötigen besonderen Kategorien personenbezogener Daten.
|
||||||
|
|
||||||
|
## 9. Hosting, Empfänger und Auftragsverarbeiter
|
||||||
|
|
||||||
|
Die Anwendung, Datenbank, Sitzungen, Sicherungen und der Mailtransport werden auf Infrastruktur der netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland, innerhalb Deutschlands beziehungsweise der Europäischen Union betrieben. Mit netcup besteht ein Vertrag über Auftragsverarbeitung.
|
||||||
|
|
||||||
|
Weitere Empfänger sind Stripe für Zahlung und Abrechnung, Behörden oder Berater bei gesetzlicher Pflicht beziehungsweise Rechtsverteidigung und die vom Kunden bestimmten Nutzer und Empfänger. Eine Weitergabe für fremde Werbung findet nicht statt.
|
||||||
|
|
||||||
|
## 10. Cookies und externe Inhalte
|
||||||
|
|
||||||
|
Die Anwendung setzt ausschließlich das Sitzungs-Cookie „kaffeeliste_session“. Es ist für Login, Mandantenzuordnung, Sicherheit und CSRF-Schutz erforderlich, enthält eine zufällige Sitzungskennung, ist für JavaScript nicht lesbar und wird beim Schließen des Browsers beendet. Grundlage für den Zugriff auf das Endgerät ist § 25 Abs. 2 Nr. 2 TDDDG; die anschließende Verarbeitung beruht auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||||
|
|
||||||
|
Es gibt keine Analyse-, Werbe- oder Social-Media-Cookies. Schriften und Gestaltungselemente werden lokal ausgeliefert; beim bloßen Seitenaufruf wird keine Verbindung zu Google Fonts hergestellt.
|
||||||
|
|
||||||
|
## 11. Speicherdauer und Löschung
|
||||||
|
|
||||||
|
- Kundenkonto und operative Mandantendaten: für die Vertragsdauer; bei einer ausdrücklich ausgelösten Mandantenlöschung sofort aus dem Produktivsystem, sonst 30 Tage nach Vertragsende
|
||||||
|
- technische Rate-Limit-Daten: höchstens 24 Stunden
|
||||||
|
- Admin- und Sicherheitsprotokoll: regelmäßig 180 Tage; bei einem konkreten Vorfall bis zu dessen Abschluss und Ablauf einschlägiger Verjährungsfristen
|
||||||
|
- E-Mail-Versandprotokoll: regelmäßig 180 Tage
|
||||||
|
- Vertragsannahmen, Kündigungen, Widerrufe und erforderliche Korrespondenz: regelmäßig bis zum Ablauf der zivilrechtlichen Verjährung, grundsätzlich drei Jahre ab Ende des Jahres, bei Rechtsstreit länger
|
||||||
|
- Rechnungen, Zahlungs- und Buchungsbelege des Anbieters: acht Jahre oder eine andere gesetzlich vorgeschriebene Dauer nach § 257 HGB und § 147 AO
|
||||||
|
- Sicherungskopien: rollierend bis zu 14 Tage; danach Überschreibung. Bei Wiederherstellung werden zuvor fällige Löschungen erneut ausgeführt.
|
||||||
|
|
||||||
|
Interne Kaffee- oder Getränkebuchungen des Kunden werden nicht pauschal als steuerliche Belege des Anbieters behandelt. Der Kunde bestimmt im Rahmen seiner Verantwortung eigene Aufbewahrungsfristen und kann den Mandanten löschen. Gesetzlich aufzubewahrende Abrechnungsdaten des Anbieters bleiben getrennt erhalten.
|
||||||
|
|
||||||
|
## 12. Ihre Rechte
|
||||||
|
|
||||||
|
Soweit CTB-IT Verantwortlicher ist, bestehen nach den gesetzlichen Voraussetzungen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch. Eine erteilte Einwilligung kann jederzeit für die Zukunft widerrufen werden; derzeit stützt der Dienst keine Werbung oder Analyse auf eine Einwilligung.
|
||||||
|
|
||||||
|
Gegen Verarbeitungen nach Art. 6 Abs. 1 lit. f DSGVO können Sie aus Gründen Ihrer besonderen Situation widersprechen. Bei Direktwerbung wäre ein Widerspruch jederzeit ohne Begründung möglich; Direktwerbung ist derzeit nicht vorgesehen.
|
||||||
|
|
||||||
|
Anfragen richten Sie an info@ctb-it.de oder über das Ticketsystem im Impressum. Sie können sich außerdem bei einer Datenschutzaufsichtsbehörde beschweren. Für den Anbieter ist insbesondere die Landesbeauftragte für den Datenschutz Niedersachsen, Prinzenstraße 5, 30159 Hannover, zuständig.
|
||||||
|
|
||||||
|
## 13. Automatisierte Entscheidungen
|
||||||
|
|
||||||
|
Es findet keine ausschließlich automatisierte Entscheidungsfindung einschließlich Profiling statt, die rechtliche Wirkung entfaltet oder Sie ähnlich erheblich beeinträchtigt. Sicherheitslimits können Anfragen kurzfristig technisch blockieren; eine Vertragsentscheidung wird damit nicht getroffen.
|
||||||
|
|
||||||
|
## 14. Änderungen
|
||||||
|
|
||||||
|
Wir aktualisieren diese Erklärung, wenn sich Verarbeitung oder Rechtslage ändern. Bei wesentlichen Änderungen für bestehende Kunden informieren wir über einen geeigneten Kontaktweg.
|
||||||
|
|
||||||
|
Stand: 22. August 2026
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -115,7 +115,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<a href="impressum.php">Impressum</a>
|
<a href="impressum.php">Impressum</a>
|
||||||
<a href="agb.php">AGB</a>
|
<a href="agb.php">AGB</a>
|
||||||
<a href="datenschutz.php">Datenschutz</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 href="preise.php">Preise</a>
|
||||||
|
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ if ($canManageSettings) {
|
|||||||
$errors = $result['errors'];
|
$errors = $result['errors'];
|
||||||
$settings = [
|
$settings = [
|
||||||
'name' => $_POST['tenant_name'] ?? '',
|
'name' => $_POST['tenant_name'] ?? '',
|
||||||
|
'customer_type' => $_POST['customer_type'] ?? 'unknown',
|
||||||
'timezone' => $_POST['timezone'] ?? 'Europe/Berlin',
|
'timezone' => $_POST['timezone'] ?? 'Europe/Berlin',
|
||||||
'locale' => $_POST['locale'] ?? 'de-DE',
|
'locale' => $_POST['locale'] ?? 'de-DE',
|
||||||
'currency_code' => $_POST['currency_code'] ?? 'EUR',
|
'currency_code' => $_POST['currency_code'] ?? 'EUR',
|
||||||
@@ -137,6 +138,14 @@ include 'nav.php';
|
|||||||
<label for="tenant_name">Kundenname</label>
|
<label for="tenant_name">Kundenname</label>
|
||||||
<input type="text" name="tenant_name" id="tenant_name" value="<?php echo saas_html($settings['name']); ?>" required>
|
<input type="text" name="tenant_name" id="tenant_name" value="<?php echo saas_html($settings['name']); ?>" required>
|
||||||
</div>
|
</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">
|
<div class="col-6 col-12-small">
|
||||||
<label for="timezone">Zeitzone</label>
|
<label for="timezone">Zeitzone</label>
|
||||||
<input type="text" name="timezone" id="timezone" value="<?php echo saas_html($settings['timezone']); ?>" required>
|
<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>
|
<p>
|
||||||
Aktueller Tarif: <b><?php echo saas_html($billingPlans[$billingCheck['current_plan']]['label'] ?? $billingCheck['current_plan']); ?></b><br>
|
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']; ?>
|
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>
|
</p>
|
||||||
<?php if (isset($_GET['upgrade']) && $_GET['upgrade'] === 'success'): ?>
|
<?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>
|
<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'): ?>
|
<?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>
|
<div class="hint-box error"><p>Der Bezahlvorgang wurde abgebrochen, es wurde nichts geändert.</p></div>
|
||||||
<?php endif; ?>
|
<?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']): ?>
|
<?php if ($billingCheck['needs_upgrade']): ?>
|
||||||
<div class="hint-box error">
|
<div class="hint-box error">
|
||||||
@@ -344,15 +361,15 @@ $watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1)
|
|||||||
<?php if ($isCurrent): ?>
|
<?php if ($isCurrent): ?>
|
||||||
—
|
—
|
||||||
<?php elseif (!$isSelfService): ?>
|
<?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): ?>
|
<?php elseif (!$selectable): ?>
|
||||||
<button type="button" class="button small" disabled>Nicht wählbar</button>
|
<button type="button" class="button small" disabled>Nicht wählbar</button>
|
||||||
<?php else: ?>
|
<?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(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
<input type="hidden" name="plan_code" value="<?php echo saas_html($planCode); ?>">
|
<input type="hidden" name="plan_code" value="<?php echo saas_html($planCode); ?>">
|
||||||
<button type="submit" class="button small primary">
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -360,8 +377,7 @@ $watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1)
|
|||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</table>
|
</table>
|
||||||
<p><small>Ein Wechsel zwischen bezahlten Tarifen wird bei Stripe anteilig verrechnet (Proration) und
|
<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>
|
||||||
gilt sofort. Details zu den Tarifgrenzen stehen unter <a href="preise.php">Preise</a>.</small></p>
|
|
||||||
|
|
||||||
<?php if (($billing['stripe_customer_id'] ?? null) !== null): ?>
|
<?php if (($billing['stripe_customer_id'] ?? null) !== null): ?>
|
||||||
<form method="post" action="abo-portal.php">
|
<form method="post" action="abo-portal.php">
|
||||||
|
|||||||
+58
-10
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/functions.php';
|
require_once __DIR__ . '/functions.php';
|
||||||
require_once __DIR__ . '/app/ledger.php';
|
require_once __DIR__ . '/app/ledger.php';
|
||||||
|
require_once __DIR__ . '/app/billing.php';
|
||||||
|
require_once __DIR__ . '/app/tenant-logo.php';
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$user = saas_require_login();
|
$user = saas_require_login();
|
||||||
@@ -13,25 +15,63 @@ if (!saas_user_has_role('owner', $user)) {
|
|||||||
|
|
||||||
$tenantId = (int)$user['tenant_id'];
|
$tenantId = (int)$user['tenant_id'];
|
||||||
$isDefaultTenant = ledger_is_default_tenant($pdo, $tenantId);
|
$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 = [];
|
$errors = [];
|
||||||
$confirmInput = '';
|
$confirmInput = '';
|
||||||
|
|
||||||
if (!$isDefaultTenant && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
if (!$isDefaultTenant && !$hasRunningPaidSubscription && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
app_require_csrf();
|
app_require_csrf();
|
||||||
$confirmInput = trim((string)($_POST['confirm_slug'] ?? ''));
|
$confirmInput = trim((string)($_POST['confirm_slug'] ?? ''));
|
||||||
|
|
||||||
if ($confirmInput !== $user['tenant_slug']) {
|
if ($confirmInput !== $user['tenant_slug']) {
|
||||||
$errors[] = 'Bitte das Kundenkürzel exakt eingeben, um die Löschung zu bestätigen.';
|
$errors[] = 'Bitte das Kundenkürzel exakt eingeben, um die Löschung zu bestätigen.';
|
||||||
} else {
|
} else {
|
||||||
// Loeschen der tenants-Zeile kaskadiert per FK auf alles
|
$settings = saas_fetch_tenant_settings($pdo, $tenantId) ?? [];
|
||||||
// tenant-scoped (participants, ledger_entries, notices,
|
$logoFiles = [
|
||||||
// tenant_memberships, audit_log, outbound_emails,
|
(string)($settings['brand_logo'] ?? ''),
|
||||||
// payment_import_batches/rows, tenant_settings). users bleiben
|
(string)($settings['pdf_watermark_logo'] ?? ''),
|
||||||
// erhalten, da ein Login-Konto zu mehreren Mandanten gehoeren kann.
|
];
|
||||||
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
|
$memberStmt = $pdo->prepare('SELECT user_id FROM tenant_memberships WHERE tenant_id = ?');
|
||||||
saas_logout();
|
$memberStmt->execute([$tenantId]);
|
||||||
header('Location: landing.php?tenant_deleted=1');
|
$userIds = array_map('intval', $memberStmt->fetchAll(PDO::FETCH_COLUMN));
|
||||||
exit;
|
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
|
selbstständig gelöscht werden. Bitte wende dich für diesen Fall an den
|
||||||
Betreiber.</p>
|
Betreiber.</p>
|
||||||
</div>
|
</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: ?>
|
<?php else: ?>
|
||||||
<div class="hint-box error">
|
<div class="hint-box error">
|
||||||
<p><b>Diese Aktion kann nicht rückgängig gemacht werden.</b> Alle Daten von
|
<p><b>Diese Aktion kann nicht rückgängig gemacht werden.</b> Alle Daten von
|
||||||
|
|||||||
@@ -112,7 +112,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<a href="impressum.php">Impressum</a>
|
<a href="impressum.php">Impressum</a>
|
||||||
<a href="agb.php">AGB</a>
|
<a href="agb.php">AGB</a>
|
||||||
<a href="datenschutz.php">Datenschutz</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 href="preise.php">Preise</a>
|
||||||
|
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<a href="impressum.php">Impressum</a>
|
<a href="impressum.php">Impressum</a>
|
||||||
<a href="agb.php">AGB</a>
|
<a href="agb.php">AGB</a>
|
||||||
<a href="datenschutz.php">Datenschutz</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 href="preise.php">Preise</a>
|
||||||
|
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
|
|||||||
+13
-9
@@ -1,4 +1,4 @@
|
|||||||
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
|
<?php require_once __DIR__ . '/app/legal.php'; ?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
@@ -52,9 +52,17 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>Beim Überschreiten einer Stufe wird automatisch zur nächsthöheren
|
<p>Es gibt keine automatische Hochstufung. Ist die Grenze erreicht,
|
||||||
gewechselt. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet – die
|
wählst du selbst einen passenden Tarif oder deaktivierst Teilnehmer.
|
||||||
genannten Beträge sind die tatsächlich zu zahlenden Endpreise.</p>
|
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">
|
<ul class="actions">
|
||||||
<li><a href="register.php" class="button primary big">Kostenlos einrichten</a></li>
|
<li><a href="register.php" class="button primary big">Kostenlos einrichten</a></li>
|
||||||
@@ -65,11 +73,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="public-footer-links">
|
<?php echo app_public_legal_footer(); ?>
|
||||||
<a href="impressum.php">Impressum</a>
|
|
||||||
<a href="agb.php">AGB</a>
|
|
||||||
<a href="datenschutz.php">Datenschutz</a>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
<script src="assets/js/browser.min.js"></script>
|
<script src="assets/js/browser.min.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/functions.php';
|
||||||
|
require_once __DIR__ . '/app/platform-admin.php';
|
||||||
|
require_once __DIR__ . '/app/audit.php';
|
||||||
|
|
||||||
|
$pdo = app_db_pdo();
|
||||||
|
$user = app_require_platform_admin($pdo);
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
header('Location: backoffice.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
app_require_csrf();
|
||||||
|
|
||||||
|
$requestId = filter_var($_POST['request_id'] ?? null, FILTER_VALIDATE_INT);
|
||||||
|
if ($requestId === false || $requestId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit('Ungültige Rechtserklärung.');
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare('SELECT tenant_id, reference_code FROM legal_requests WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$requestId]);
|
||||||
|
$request = $stmt->fetch();
|
||||||
|
if ($request === false) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Rechtserklärung nicht gefunden.');
|
||||||
|
}
|
||||||
|
$pdo->prepare("UPDATE legal_requests SET status = 'processed', processed_at = NOW() WHERE id = ?")
|
||||||
|
->execute([$requestId]);
|
||||||
|
app_audit_log(
|
||||||
|
$pdo,
|
||||||
|
$request['tenant_id'] !== null ? (int)$request['tenant_id'] : null,
|
||||||
|
(int)$user['user_id'],
|
||||||
|
'legal_request.processed',
|
||||||
|
'legal_request',
|
||||||
|
(int)$requestId,
|
||||||
|
['reference_code' => (string)$request['reference_code']]
|
||||||
|
);
|
||||||
|
|
||||||
|
header('Location: backoffice.php?legal_request=processed');
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/app/database.php';
|
||||||
|
require_once __DIR__ . '/app/legal-requests.php';
|
||||||
|
|
||||||
|
$token = (string)($_GET['token'] ?? '');
|
||||||
|
$request = app_fetch_legal_request_by_token(app_db_pdo(), $token);
|
||||||
|
if ($request === null) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Bestätigung nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestMetadata = json_decode((string)($request['metadata_json'] ?? ''), true);
|
||||||
|
$input = [
|
||||||
|
'request_type' => $request['request_type'],
|
||||||
|
'requester_name' => $request['requester_name'],
|
||||||
|
'requester_email' => $request['requester_email'],
|
||||||
|
'contract_reference' => $request['contract_reference'],
|
||||||
|
'request_reason' => $request['request_reason'],
|
||||||
|
'cancellation_kind' => is_array($requestMetadata) && ($requestMetadata['cancellation_kind'] ?? null) === 'extraordinary' ? 'extraordinary' : 'ordinary',
|
||||||
|
];
|
||||||
|
$processing = [
|
||||||
|
'effective_end' => is_array($requestMetadata) && !empty($requestMetadata['effective_end'])
|
||||||
|
? (string)$requestMetadata['effective_end']
|
||||||
|
: ($request['requested_end'] === 'earliest' ? 'zum frühestmöglichen Zeitpunkt' : (string)($request['requested_end'] ?? '')),
|
||||||
|
'processing_note' => 'Die Erklärung wurde unter dem angegebenen Zeitpunkt gespeichert.',
|
||||||
|
];
|
||||||
|
$stored = ['reference_code' => $request['reference_code'], 'received_at' => $request['received_at']];
|
||||||
|
$text = app_legal_request_confirmation_text($input, $stored, $processing);
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Referrer-Policy: no-referrer');
|
||||||
|
header('Content-Type: text/plain; charset=UTF-8');
|
||||||
|
if (isset($_GET['download'])) {
|
||||||
|
header('Content-Disposition: attachment; filename="kaffeeliste-bestaetigung-' . $request['reference_code'] . '.txt"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo $text;
|
||||||
+48
-3
@@ -3,6 +3,7 @@
|
|||||||
require_once __DIR__ . '/functions.php';
|
require_once __DIR__ . '/functions.php';
|
||||||
require_once __DIR__ . '/app/saas-mail.php';
|
require_once __DIR__ . '/app/saas-mail.php';
|
||||||
require_once __DIR__ . '/app/rate-limit.php';
|
require_once __DIR__ . '/app/rate-limit.php';
|
||||||
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$errors = [];
|
$errors = [];
|
||||||
@@ -11,6 +12,7 @@ $values = [
|
|||||||
'tenant_slug' => trim((string)($_POST['tenant_slug'] ?? '')),
|
'tenant_slug' => trim((string)($_POST['tenant_slug'] ?? '')),
|
||||||
'display_name' => trim((string)($_POST['display_name'] ?? '')),
|
'display_name' => trim((string)($_POST['display_name'] ?? '')),
|
||||||
'email' => trim((string)($_POST['email'] ?? '')),
|
'email' => trim((string)($_POST['email'] ?? '')),
|
||||||
|
'customer_type' => (string)($_POST['customer_type'] ?? ''),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
@@ -26,13 +28,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'email' => $values['email'],
|
'email' => $values['email'],
|
||||||
'password' => (string)($_POST['password'] ?? ''),
|
'password' => (string)($_POST['password'] ?? ''),
|
||||||
'password_confirm' => (string)($_POST['password_confirm'] ?? ''),
|
'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']) {
|
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'])) {
|
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']);
|
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'])) {
|
if (saas_should_show_auth_links() && !empty($result['email_verification_token'])) {
|
||||||
$_SESSION['saas_dev_email_verification_token'] = (string)$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-grid">
|
||||||
<div class="public-auth-copy">
|
<div class="public-auth-copy">
|
||||||
<h1>Registrierung</h1>
|
<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>
|
||||||
|
|
||||||
<div class="public-panel">
|
<div class="public-panel">
|
||||||
@@ -85,6 +104,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<form method="post" action="register.php">
|
<form method="post" action="register.php">
|
||||||
<?php echo app_csrf_field(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
<div class="row">
|
<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">
|
<div class="col-6 col-12-small">
|
||||||
<label for="tenant_name">Kundenname</label>
|
<label for="tenant_name">Kundenname</label>
|
||||||
<input type="text" name="tenant_name" id="tenant_name" value="<?php echo saas_html($values['tenant_name']); ?>" required>
|
<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>
|
<label for="password_confirm">Passwort wiederholen</label>
|
||||||
<input type="password" name="password_confirm" id="password_confirm" minlength="8" required>
|
<input type="password" name="password_confirm" id="password_confirm" minlength="8" required>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
<p><small>Das Kundenkonto ist kostenlos. Verbraucher haben ein 14-tägiges <a href="widerruf.php">Widerrufsrecht</a>.</small></p>
|
||||||
<ul class="actions">
|
<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>
|
<li><a href="login.php" class="button">Zum Login</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</form>
|
</form>
|
||||||
@@ -124,7 +165,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<a href="impressum.php">Impressum</a>
|
<a href="impressum.php">Impressum</a>
|
||||||
<a href="agb.php">AGB</a>
|
<a href="agb.php">AGB</a>
|
||||||
<a href="datenschutz.php">Datenschutz</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 href="preise.php">Preise</a>
|
||||||
|
<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="assets/js/jquery.min.js"></script>
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ Allow: /preise.php
|
|||||||
Allow: /impressum.php
|
Allow: /impressum.php
|
||||||
Allow: /agb.php
|
Allow: /agb.php
|
||||||
Allow: /datenschutz.php
|
Allow: /datenschutz.php
|
||||||
|
Allow: /avv.php
|
||||||
|
Allow: /widerruf.php
|
||||||
|
Allow: /widerrufen.php
|
||||||
|
Allow: /kuendigen.php
|
||||||
|
Allow: /legal/
|
||||||
Disallow: /index.php
|
Disallow: /index.php
|
||||||
Disallow: /login.php
|
Disallow: /login.php
|
||||||
Disallow: /register.php
|
Disallow: /register.php
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -53,6 +53,10 @@ $result = saas_register_tenant_owner($pdo, [
|
|||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
'password_confirm' => $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);
|
auth_check_assert('registration succeeds', $result['ok'] === true, $failures, $passes);
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ $registration = saas_register_tenant_owner($pdo, [
|
|||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
'password_confirm' => $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);
|
password_email_check_assert('registration succeeds', $registration['ok'] === true, $failures, $passes);
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ $registration = saas_register_tenant_owner($pdo, [
|
|||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
'password_confirm' => $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);
|
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).
|
// (pdf_row_height_px, payment_reminder_interval_days).
|
||||||
$update = saas_update_tenant_settings($pdo, $tenantId, [
|
$update = saas_update_tenant_settings($pdo, $tenantId, [
|
||||||
'tenant_name' => 'M3 Settings Flow Updated',
|
'tenant_name' => 'M3 Settings Flow Updated',
|
||||||
|
'customer_type' => 'business',
|
||||||
'timezone' => 'Europe/Berlin',
|
'timezone' => 'Europe/Berlin',
|
||||||
'locale' => 'de-DE',
|
'locale' => 'de-DE',
|
||||||
'currency_code' => 'EUR',
|
'currency_code' => 'EUR',
|
||||||
@@ -114,6 +119,7 @@ if ($registration['ok']) {
|
|||||||
// Ab einem bezahlten Tarif entscheidet der Mandant selbst.
|
// Ab einem bezahlten Tarif entscheidet der Mandant selbst.
|
||||||
$paidInput = [
|
$paidInput = [
|
||||||
'tenant_name' => 'M3 Settings Flow Updated',
|
'tenant_name' => 'M3 Settings Flow Updated',
|
||||||
|
'customer_type' => 'business',
|
||||||
'timezone' => 'Europe/Berlin',
|
'timezone' => 'Europe/Berlin',
|
||||||
'locale' => 'de-DE',
|
'locale' => 'de-DE',
|
||||||
'currency_code' => 'EUR',
|
'currency_code' => 'EUR',
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ $first = saas_register_tenant_owner($pdo, [
|
|||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
'password_confirm' => $password,
|
'password_confirm' => $password,
|
||||||
|
'customer_type' => 'business',
|
||||||
|
'accept_terms' => true,
|
||||||
|
'acknowledge_privacy' => true,
|
||||||
|
'accept_dpa' => true,
|
||||||
]);
|
]);
|
||||||
$second = saas_register_tenant_owner($pdo, [
|
$second = saas_register_tenant_owner($pdo, [
|
||||||
'tenant_name' => 'M3 Tenant Choice B',
|
'tenant_name' => 'M3 Tenant Choice B',
|
||||||
@@ -66,6 +70,10 @@ $second = saas_register_tenant_owner($pdo, [
|
|||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
'password_confirm' => $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);
|
tenant_resolution_assert('first registration succeeds', $first['ok'] === true, $failures, $passes);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ $suffix = bin2hex(random_bytes(4));
|
|||||||
$password = 'RoleMatrixTest123!';
|
$password = 'RoleMatrixTest123!';
|
||||||
$roles = ['owner', 'admin', 'treasurer', 'member', 'viewer'];
|
$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
|
// Seite => Rollen, die Zugriff haben sollen. Alle anderen Rollen muessen
|
||||||
// abgewiesen werden. Muss mit den saas_user_has_role()-Aufrufen in den
|
// abgewiesen werden. Muss mit den saas_user_has_role()-Aufrufen in den
|
||||||
// jeweiligen Dateien uebereinstimmen.
|
// 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 tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
|
||||||
$pdo->prepare('DELETE FROM tenants WHERE 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 !== []) {
|
if ($failures !== []) {
|
||||||
echo "\nM8 role matrix check failed with " . count($failures) . " failure(s):\n";
|
echo "\nM8 role matrix check failed with " . count($failures) . " failure(s):\n";
|
||||||
|
|||||||
@@ -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
@@ -35,12 +35,12 @@ $checks = [
|
|||||||
[
|
[
|
||||||
'label' => 'Impressum',
|
'label' => 'Impressum',
|
||||||
'path' => 'impressum.php',
|
'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',
|
'label' => 'AGB',
|
||||||
'path' => 'agb.php',
|
'path' => 'agb.php',
|
||||||
'contains' => ['Allgemeine Geschäftsbedingungen', 'Entwurf'],
|
'contains' => ['Allgemeine Geschäftsbedingungen', 'Verbraucher', 'Verträge hier kündigen'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'label' => 'Preise',
|
'label' => 'Preise',
|
||||||
@@ -52,6 +52,26 @@ $checks = [
|
|||||||
'path' => 'datenschutz.php',
|
'path' => 'datenschutz.php',
|
||||||
'contains' => ['Datenschutzerklärung', 'Verantwortlicher', 'Auftragsverarbeiter'],
|
'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',
|
'label' => 'Dashboard',
|
||||||
'path' => 'index.php',
|
'path' => 'index.php',
|
||||||
@@ -396,6 +416,10 @@ $passes = 0;
|
|||||||
|
|
||||||
echo "HTTP smoke base URL: {$baseUrl}\n";
|
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.
|
// Testmandant anlegen und per HTTP anmelden.
|
||||||
try {
|
try {
|
||||||
$seeded = smoke_seed($pdo, $smokeSlug, $smokeEmail, $smokePassword, $suffix);
|
$seeded = smoke_seed($pdo, $smokeSlug, $smokeEmail, $smokePassword, $suffix);
|
||||||
@@ -415,16 +439,17 @@ foreach ($checks as $i => $check) {
|
|||||||
$authCookies = [];
|
$authCookies = [];
|
||||||
$loginPage = smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies);
|
$loginPage = smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies);
|
||||||
preg_match('/name="csrf_token" value="([^"]+)"/', $loginPage['body'], $csrfMatch);
|
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] ?? '',
|
'csrf_token' => $csrfMatch[1] ?? '',
|
||||||
'email' => $smokeEmail,
|
'email' => $smokeEmail,
|
||||||
'password' => $smokePassword,
|
'password' => $smokePassword,
|
||||||
'tenant_slug' => $smokeSlug,
|
'tenant_slug' => $smokeSlug,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
if (empty($authCookies)) {
|
if ($loginResponse['status'] !== 302 || !str_contains((string)$loginResponse['location'], 'index.php')) {
|
||||||
smoke_cleanup($pdo, $smokeTenantId, $smokeEmail);
|
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);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,6 +544,7 @@ foreach ($skippedUnsafe as $skip) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
smoke_cleanup($pdo, $smokeTenantId, $smokeEmail);
|
smoke_cleanup($pdo, $smokeTenantId, $smokeEmail);
|
||||||
|
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
|
||||||
|
|
||||||
if ($failures !== []) {
|
if ($failures !== []) {
|
||||||
echo "\nHTTP smoke failed with " . count($failures) . " failure(s):\n";
|
echo "\nHTTP smoke failed with " . count($failures) . " failure(s):\n";
|
||||||
|
|||||||
@@ -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
@@ -8,7 +8,17 @@ $scheme = app_is_https() ? 'https' : 'http';
|
|||||||
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
|
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
|
||||||
$base = $host !== '' ? $scheme . '://' . $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');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||||
|
|||||||
+11
-1
@@ -6,6 +6,7 @@ require_once __DIR__ . '/app/database.php';
|
|||||||
require_once __DIR__ . '/app/billing.php';
|
require_once __DIR__ . '/app/billing.php';
|
||||||
require_once __DIR__ . '/app/audit.php';
|
require_once __DIR__ . '/app/audit.php';
|
||||||
require_once __DIR__ . '/app/dolibarr.php';
|
require_once __DIR__ . '/app/dolibarr.php';
|
||||||
|
require_once __DIR__ . '/app/saas-mail.php';
|
||||||
|
|
||||||
// Kein app_require_csrf()/saas_require_login(): Stripe ruft diesen
|
// Kein app_require_csrf()/saas_require_login(): Stripe ruft diesen
|
||||||
// Endpunkt unauthentifiziert von aussen auf. Die Echtheit wird
|
// Endpunkt unauthentifiziert von aussen auf. Die Echtheit wird
|
||||||
@@ -55,6 +56,12 @@ switch ($event['type']) {
|
|||||||
'stripe_subscription_id' => (string)$object['subscription'],
|
'stripe_subscription_id' => (string)$object['subscription'],
|
||||||
]);
|
]);
|
||||||
app_audit_log($pdo, $tenantId, null, 'billing.subscription_started', 'tenant', $tenantId, ['plan_code' => $planCode]);
|
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;
|
break;
|
||||||
|
|
||||||
@@ -63,6 +70,9 @@ switch ($event['type']) {
|
|||||||
$tenantId ??= billing_find_tenant_id_by_stripe_subscription($pdo, (string)($object['id'] ?? ''));
|
$tenantId ??= billing_find_tenant_id_by_stripe_subscription($pdo, (string)($object['id'] ?? ''));
|
||||||
if ($tenantId !== null) {
|
if ($tenantId !== null) {
|
||||||
$status = (string)($object['status'] ?? 'active');
|
$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'])
|
$periodEnd = isset($object['current_period_end'])
|
||||||
? date('Y-m-d H:i:s', (int)$object['current_period_end'])
|
? date('Y-m-d H:i:s', (int)$object['current_period_end'])
|
||||||
: null;
|
: null;
|
||||||
@@ -77,7 +87,7 @@ switch ($event['type']) {
|
|||||||
// eindeutiger Zuordnung, sonst bleibt der bisherige Plan bestehen.
|
// eindeutiger Zuordnung, sonst bleibt der bisherige Plan bestehen.
|
||||||
$lookupKey = (string)($object['items']['data'][0]['price']['lookup_key'] ?? '');
|
$lookupKey = (string)($object['items']['data'][0]['price']['lookup_key'] ?? '');
|
||||||
$mappedPlan = billing_plan_code_by_lookup_key($lookupKey);
|
$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;
|
$updateFields['plan_code'] = $mappedPlan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">← 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
@@ -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']); ?>&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>
|
||||||
Reference in New Issue
Block a user