Rechtstexte und B2C-Vertragsabläufe absichern

This commit is contained in:
2026-08-22 14:31:56 +02:00
parent d320a4fd7a
commit a31a235422
58 changed files with 2502 additions and 316 deletions
+301
View File
@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/legal.php';
require_once __DIR__ . '/saas-mail.php';
require_once __DIR__ . '/rate-limit.php';
require_once __DIR__ . '/billing.php';
/** @return list<string> */
function app_validate_legal_request(array $input, string $requestType): array
{
$errors = [];
$name = trim((string)($input['requester_name'] ?? ''));
$email = trim((string)($input['requester_email'] ?? ''));
$reference = trim((string)($input['contract_reference'] ?? ''));
if (strlen($name) < 2 || strlen($name) > 255) {
$errors[] = 'Bitte gib deinen vollständigen Namen an.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Bitte gib eine gültige E-Mail-Adresse für die Bestätigung an.';
}
if (!preg_match('/^[a-z0-9][a-z0-9-]{1,98}[a-z0-9]$/', strtolower($reference))) {
$errors[] = 'Bitte gib das Kundenkürzel aus deinem Konto an.';
}
if ($requestType === 'cancellation') {
$kind = (string)($input['cancellation_kind'] ?? '');
if (!in_array($kind, ['ordinary', 'extraordinary'], true)) {
$errors[] = 'Bitte wähle die Art der Kündigung.';
}
if ($kind === 'extraordinary' && trim((string)($input['request_reason'] ?? '')) === '') {
$errors[] = 'Bitte nenne bei einer außerordentlichen Kündigung den Kündigungsgrund.';
}
$requestedEnd = (string)($input['requested_end'] ?? '');
if ($requestedEnd !== 'earliest') {
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $requestedEnd);
if ($date === false || $date->format('Y-m-d') !== $requestedEnd) {
$errors[] = 'Bitte wähle „frühestmöglich“ oder ein gültiges Beendigungsdatum.';
}
}
}
if (strlen((string)($input['request_reason'] ?? '')) > 5000) {
$errors[] = 'Der Grund ist zu lang (höchstens 5.000 Zeichen).';
}
return $errors;
}
function app_update_legal_request(PDO $pdo, string $referenceCode, string $status, array $metadata = []): void
{
$stmt = $pdo->prepare(
'UPDATE legal_requests SET status = ?, metadata_json = ?, processed_at = NOW() WHERE reference_code = ?'
);
$stmt->execute([
$status,
$metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
$referenceCode,
]);
}
/**
* Ordentliche Kündigungen eines eindeutig zugeordneten Stripe-Abos werden
* sofort zum Periodenende vorgemerkt. Alle anderen Erklärungen bleiben als
* fristwahrender Eingang in der DB und werden manuell bearbeitet.
*
* @return array{status: string, effective_end: string, processing_note: string}
*/
function app_process_cancellation_request(PDO $pdo, array $stored, array $input): array
{
$effectiveEnd = (string)$input['requested_end'] === 'earliest'
? 'zum frühestmöglichen Zeitpunkt'
: (string)$input['requested_end'];
$manual = [
'status' => 'received_manual',
'effective_end' => $effectiveEnd,
'processing_note' => 'Die Erklärung ist eingegangen und wird anhand der Vertragsdaten bearbeitet.',
];
if ($stored['tenant_id'] === null
|| (string)$input['cancellation_kind'] !== 'ordinary'
|| (string)$input['requested_end'] !== 'earliest'
) {
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
'cancellation_kind' => (string)$input['cancellation_kind'],
]);
return $manual;
}
$billing = billing_fetch_or_init($pdo, (int)$stored['tenant_id']);
if (empty($billing['stripe_subscription_id'])) {
if ((string)$billing['plan_code'] === 'free') {
$pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = NOW() WHERE id = ?')
->execute([(int)$stored['tenant_id']]);
$effectiveEnd = date('d.m.Y, H:i') . ' Uhr';
app_update_legal_request($pdo, (string)$stored['reference_code'], 'contract_ended', [
'cancellation_kind' => (string)$input['cancellation_kind'],
'effective_end' => $effectiveEnd,
]);
return [
'status' => 'contract_ended',
'effective_end' => $effectiveEnd,
'processing_note' => 'Der kostenlose Vertrag ist beendet. Die Anmeldung ist nicht mehr möglich; operative Mandantendaten werden nach 30 Tagen gelöscht.',
];
}
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
'cancellation_kind' => (string)$input['cancellation_kind'],
]);
return $manual;
}
try {
$result = stripe_schedule_subscription_cancellation((string)$billing['stripe_subscription_id']);
} catch (Throwable $e) {
$result = ['ok' => false, 'current_period_end' => null, 'error' => $e->getMessage()];
}
if (!$result['ok']) {
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
'cancellation_kind' => (string)$input['cancellation_kind'],
'stripe_error' => (string)$result['error'],
]);
return $manual;
}
billing_update($pdo, (int)$stored['tenant_id'], [
'subscription_status' => 'canceling',
'current_period_end' => $result['current_period_end'],
]);
if ($result['current_period_end'] !== null) {
$pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = ? WHERE id = ?')
->execute([$result['current_period_end'], (int)$stored['tenant_id']]);
}
$effectiveEnd = $result['current_period_end'] !== null
? date('d.m.Y, H:i', strtotime((string)$result['current_period_end'])) . ' Uhr'
: 'zum Ende der laufenden Abrechnungsperiode';
app_update_legal_request($pdo, (string)$stored['reference_code'], 'cancellation_scheduled', [
'cancellation_kind' => (string)$input['cancellation_kind'],
'effective_end' => $effectiveEnd,
]);
return [
'status' => 'cancellation_scheduled',
'effective_end' => $effectiveEnd,
'processing_note' => 'Das kostenpflichtige Abonnement wurde bei Stripe zum Ende der laufenden Abrechnungsperiode vorgemerkt.',
];
}
/**
* @return array{status: string, effective_end: string, processing_note: string}
*/
function app_process_withdrawal_request(PDO $pdo, array $stored): array
{
$manual = [
'status' => 'received_manual',
'effective_end' => 'mit Eingang des Widerrufs, sofern das Widerrufsrecht besteht',
'processing_note' => 'Der Widerruf ist eingegangen und wird unverzüglich anhand der Vertragsdaten geprüft.',
];
if ($stored['tenant_id'] === null) {
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual');
return $manual;
}
$stmt = $pdo->prepare('SELECT customer_type, created_at FROM tenants WHERE id = ?');
$stmt->execute([(int)$stored['tenant_id']]);
$tenant = $stmt->fetch();
if ($tenant === false) {
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual');
return $manual;
}
$billing = billing_fetch_or_init($pdo, (int)$stored['tenant_id']);
if ((string)$billing['plan_code'] === 'free') {
$stmt = $pdo->prepare(
"SELECT accepted_at, metadata_json FROM legal_acceptances
WHERE tenant_id = ? AND document_type = 'terms' AND context = 'registration'
ORDER BY accepted_at ASC LIMIT 1"
);
$stmt->execute([(int)$stored['tenant_id']]);
$contractEvidence = $stmt->fetch();
$contractStart = $contractEvidence !== false
? strtotime((string)$contractEvidence['accepted_at'])
: strtotime((string)$tenant['created_at']);
} else {
$stmt = $pdo->prepare(
"SELECT accepted_at, metadata_json FROM legal_acceptances
WHERE tenant_id = ? AND document_type = 'withdrawal_information' AND context = 'paid_order'
ORDER BY accepted_at DESC LIMIT 1"
);
$stmt->execute([(int)$stored['tenant_id']]);
$contractEvidence = $stmt->fetch();
$contractStart = $contractEvidence !== false ? strtotime((string)$contractEvidence['accepted_at']) : false;
}
$contractMetadata = $contractEvidence !== false
? json_decode((string)($contractEvidence['metadata_json'] ?? ''), true)
: null;
$contractCustomerType = is_array($contractMetadata)
? (string)($contractMetadata['customer_type'] ?? $tenant['customer_type'])
: (string)$tenant['customer_type'];
if ($contractCustomerType !== 'consumer') {
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
'review_reason' => 'contract_not_recorded_as_consumer',
]);
return $manual;
}
if ($contractStart === false || $contractStart < strtotime('-14 days')) {
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
'review_reason' => 'automatic_14_day_window_not_proven',
]);
return $manual;
}
$stripeError = null;
if (!empty($billing['stripe_subscription_id'])) {
try {
$cancel = stripe_cancel_subscription((string)$billing['stripe_subscription_id']);
if (!$cancel['ok']) {
$stripeError = (string)$cancel['error'];
}
} catch (Throwable $e) {
$stripeError = $e->getMessage();
}
}
$pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = NOW() WHERE id = ?')
->execute([(int)$stored['tenant_id']]);
if ($stripeError === null && !empty($billing['stripe_subscription_id'])) {
billing_update($pdo, (int)$stored['tenant_id'], [
'plan_code' => 'free',
'subscription_status' => 'canceled',
]);
}
$status = $stripeError === null ? 'withdrawal_effective' : 'withdrawal_action_required';
$effectiveEnd = date('d.m.Y, H:i') . ' Uhr';
app_update_legal_request($pdo, (string)$stored['reference_code'], $status, [
'effective_end' => $effectiveEnd,
'stripe_error' => $stripeError,
'refund_review_required' => (string)$billing['plan_code'] !== 'free',
]);
return [
'status' => $status,
'effective_end' => $effectiveEnd,
'processing_note' => $stripeError === null
? 'Der Vertrag wurde beendet. Eine gegebenenfalls geschuldete Rückzahlung wird unverzüglich über das ursprüngliche Zahlungsmittel bearbeitet.'
: 'Der Vertrag wurde lokal beendet. Die Beendigung beim Zahlungsdienst und eine gegebenenfalls geschuldete Rückzahlung müssen noch manuell abgeschlossen werden.',
];
}
function app_legal_request_confirmation_text(array $input, array $stored, array $processing): string
{
$isCancellation = (string)$input['request_type'] === 'cancellation';
$type = $isCancellation ? 'Kündigung' : 'Widerruf';
$lines = [
"Eingangsbestätigung {$type} Kaffeeliste",
'',
'Referenz: ' . $stored['reference_code'],
'Eingang: ' . $stored['received_at'] . ' (Europe/Berlin)',
'Name: ' . $input['requester_name'],
'Bestätigungs-E-Mail: ' . $input['requester_email'],
'Kundenkürzel / Vertragsreferenz: ' . $input['contract_reference'],
];
if ($isCancellation) {
$lines[] = 'Art: ' . ((string)$input['cancellation_kind'] === 'extraordinary' ? 'außerordentliche Kündigung' : 'ordentliche Kündigung');
$lines[] = 'Beendigung: ' . $processing['effective_end'];
}
if (trim((string)($input['request_reason'] ?? '')) !== '') {
$lines[] = 'Erklärung / Grund: ' . trim((string)$input['request_reason']);
}
$lines[] = '';
$lines[] = $processing['processing_note'];
$lines[] = '';
$lines[] = 'Clemens Creutzburg, CTB-IT';
$lines[] = 'In den Sieben Stücken 9d, 30655 Hannover';
$lines[] = app_legal_email();
return implode("\n", $lines);
}
function app_send_legal_request_confirmations(array $input, array $stored, array $processing): array
{
$text = app_legal_request_confirmation_text($input, $stored, $processing);
$downloadUrl = saas_app_url('rechtserklaerung-bestaetigung.php?token=' . urlencode((string)$stored['access_token']) . '&download=1');
$type = (string)$input['request_type'] === 'cancellation' ? 'Kündigung' : 'Widerruf';
$customerResult = saas_send_mail(
(string)$input['requester_email'],
"Kaffeeliste: Eingang {$type} " . $stored['reference_code'],
$text . "\n\nBestätigung als Textdatei speichern:\n" . $downloadUrl
);
$operatorResult = saas_send_mail(
app_legal_email(),
"Kaffeeliste: {$type} eingegangen " . $stored['reference_code'],
$text . "\n\nInterner Status: " . $processing['status']
);
return [
'customer_ok' => (bool)($customerResult['ok'] ?? false),
'operator_ok' => (bool)($operatorResult['ok'] ?? false),
];
}