Files
kaffeekasse-saas/app/legal-requests.php
T

302 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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),
];
}