302 lines
13 KiB
PHP
302 lines
13 KiB
PHP
<?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),
|
||
];
|
||
}
|