Rechtstexte und B2C-Vertragsabläufe absichern
This commit is contained in:
@@ -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),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user