*/ 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), ]; }