Rechtstexte und B2C-Vertragsabläufe absichern
This commit is contained in:
@@ -18,6 +18,7 @@ function app_audit_log(
|
||||
?int $subjectId,
|
||||
array $metadata = []
|
||||
): void {
|
||||
$pdo->exec('DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO audit_log (tenant_id, actor_user_id, action, subject_type, subject_id, metadata_json, ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
|
||||
+54
-1
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/tenant-logo.php';
|
||||
|
||||
/**
|
||||
* Assembles a full export of everything stored for one tenant (data
|
||||
@@ -13,7 +14,7 @@ require_once __DIR__ . '/bootstrap.php';
|
||||
*/
|
||||
function app_export_tenant_data(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id, slug, name, status, timezone, locale, currency_code, created_at, updated_at FROM tenants WHERE id = ?');
|
||||
$stmt = $pdo->prepare('SELECT id, slug, paypal_inbox_token, name, customer_type, status, timezone, locale, currency_code, created_at, updated_at FROM tenants WHERE id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$tenant = $stmt->fetch();
|
||||
|
||||
@@ -66,6 +67,51 @@ function app_export_tenant_data(PDO $pdo, int $tenantId): array
|
||||
$stmt->execute([$tenantId]);
|
||||
$auditLog = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM faq_entries WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$faqEntries = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM paypal_payments WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$paypalPayments = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM tenant_billing WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$billing = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM tenant_features WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$features = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT document_type, document_version, context, metadata_json, accepted_at
|
||||
FROM legal_acceptances WHERE tenant_id = ? ORDER BY id'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$legalAcceptances = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT reference_code, request_type, requester_name, requester_email,
|
||||
contract_reference, request_reason, requested_end, status,
|
||||
metadata_json, received_at, processed_at
|
||||
FROM legal_requests WHERE tenant_id = ? ORDER BY id'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$legalRequests = $stmt->fetchAll();
|
||||
|
||||
$logos = [];
|
||||
foreach (['brand_logo', 'pdf_watermark_logo'] as $logoField) {
|
||||
$filename = (string)($settings[$logoField] ?? '');
|
||||
$path = tenant_logo_path($filename);
|
||||
if ($path !== null) {
|
||||
$logos[$logoField] = [
|
||||
'filename' => $filename,
|
||||
'mime_type' => mime_content_type($path) ?: 'application/octet-stream',
|
||||
'base64' => base64_encode((string)file_get_contents($path)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'exported_at' => date('c'),
|
||||
'tenant' => $tenant ?: null,
|
||||
@@ -78,5 +124,12 @@ function app_export_tenant_data(PDO $pdo, int $tenantId): array
|
||||
'payment_import_rows' => $importRows,
|
||||
'outbound_emails' => $outboundEmails,
|
||||
'audit_log' => $auditLog,
|
||||
'faq_entries' => $faqEntries,
|
||||
'paypal_payments' => $paypalPayments,
|
||||
'billing' => $billing ?: null,
|
||||
'features' => $features,
|
||||
'legal_acceptances' => $legalAcceptances,
|
||||
'legal_requests' => $legalRequests,
|
||||
'logos' => $logos,
|
||||
];
|
||||
}
|
||||
|
||||
+7
-1
@@ -110,6 +110,12 @@ function app_feature_enabled(PDO $pdo, int $tenantId, string $featureKey): bool
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
// Export und Rückgabe der eigenen Daten sind Bestandteil von Vertrag und
|
||||
// AVV und dürfen nicht versehentlich im Back-Office abgeschaltet werden.
|
||||
if ($featureKey === 'data_export') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
return app_feature_default($featureKey);
|
||||
}
|
||||
@@ -141,7 +147,7 @@ function app_features_update(PDO $pdo, int $tenantId, array $submittedFeatures):
|
||||
);
|
||||
|
||||
foreach (array_keys(app_feature_catalog()) as $key) {
|
||||
$enabled = !empty($submittedFeatures[$key]);
|
||||
$enabled = $key === 'data_export' || !empty($submittedFeatures[$key]);
|
||||
$stmt->execute([$tenantId, $key, $enabled ? 1 : 0]);
|
||||
if (($before[$key] ?? true) !== $enabled) {
|
||||
$changed[] = $key;
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
const APP_TERMS_VERSION = '2026-08-22';
|
||||
const APP_PRIVACY_VERSION = '2026-08-22';
|
||||
const APP_DPA_VERSION = '2026-08-22';
|
||||
const APP_WITHDRAWAL_VERSION = '2026-08-22';
|
||||
|
||||
function app_legal_email(): string
|
||||
{
|
||||
return (string)app_env('LEGAL_EMAIL', 'info@ctb-it.de');
|
||||
}
|
||||
|
||||
function app_legal_phone(): string
|
||||
{
|
||||
return trim((string)app_env('LEGAL_PHONE', ''));
|
||||
}
|
||||
|
||||
function app_legal_ticket_url(): string
|
||||
{
|
||||
return rtrim((string)app_env('LEGAL_TICKET_URL', 'https://ticketsystem.ctb-it.de'), '/');
|
||||
}
|
||||
|
||||
function app_public_legal_footer(): string
|
||||
{
|
||||
return '<footer class="public-footer-links">'
|
||||
. '<a href="impressum.php">Impressum</a>'
|
||||
. '<a href="agb.php">AGB</a>'
|
||||
. '<a href="datenschutz.php">Datenschutz</a>'
|
||||
. '<a href="avv.php">AVV</a>'
|
||||
. '<a href="widerruf.php">Widerrufsbelehrung</a>'
|
||||
. '<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>'
|
||||
. '<a href="preise.php">Preise</a>'
|
||||
. '<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>'
|
||||
. '</footer>';
|
||||
}
|
||||
|
||||
function app_legal_document_version(string $documentType): string
|
||||
{
|
||||
return match ($documentType) {
|
||||
'terms' => APP_TERMS_VERSION,
|
||||
'privacy_notice' => APP_PRIVACY_VERSION,
|
||||
'dpa' => APP_DPA_VERSION,
|
||||
'withdrawal_information', 'early_performance' => APP_WITHDRAWAL_VERSION,
|
||||
default => throw new InvalidArgumentException('Unbekannter Rechtstext.'),
|
||||
};
|
||||
}
|
||||
|
||||
function app_legal_document_text(string $documentType): string
|
||||
{
|
||||
$filename = match ($documentType) {
|
||||
'terms' => 'agb-' . APP_TERMS_VERSION . '.txt',
|
||||
'privacy_notice' => 'datenschutz-' . APP_PRIVACY_VERSION . '.txt',
|
||||
'dpa' => 'avv-' . APP_DPA_VERSION . '.txt',
|
||||
'withdrawal_information', 'early_performance' => 'widerruf-' . APP_WITHDRAWAL_VERSION . '.txt',
|
||||
default => throw new InvalidArgumentException('Unbekannter Rechtstext.'),
|
||||
};
|
||||
$path = APP_ROOT . '/legal/' . $filename;
|
||||
$content = is_file($path) ? file_get_contents($path) : false;
|
||||
if ($content === false) {
|
||||
throw new RuntimeException('Rechtstext fehlt: ' . $filename);
|
||||
}
|
||||
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
function app_render_legal_document(string $documentType): string
|
||||
{
|
||||
$blocks = preg_split('/\R\s*\R/', app_legal_document_text($documentType)) ?: [];
|
||||
$html = '';
|
||||
foreach ($blocks as $block) {
|
||||
$block = trim($block);
|
||||
if ($block === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($block, '## ')) {
|
||||
$html .= '<h2>' . htmlspecialchars(substr($block, 3), ENT_QUOTES, 'UTF-8') . '</h2>';
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($block, '### ')) {
|
||||
$html .= '<h3>' . htmlspecialchars(substr($block, 4), ENT_QUOTES, 'UTF-8') . '</h3>';
|
||||
continue;
|
||||
}
|
||||
$lines = preg_split('/\R/', $block) ?: [];
|
||||
if ($lines !== [] && count(array_filter($lines, static fn(string $line): bool => str_starts_with(trim($line), '- '))) === count($lines)) {
|
||||
$html .= '<ul>';
|
||||
foreach ($lines as $line) {
|
||||
$html .= '<li>' . htmlspecialchars(substr(trim($line), 2), ENT_QUOTES, 'UTF-8') . '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
continue;
|
||||
}
|
||||
$escaped = htmlspecialchars($block, ENT_QUOTES, 'UTF-8');
|
||||
$escaped = preg_replace_callback(
|
||||
'~\[(.+?)\]\((https?://[^)]+|[a-z0-9-]+\.php)\)~i',
|
||||
static fn(array $match): string => '<a href="' . htmlspecialchars($match[2], ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($match[1], ENT_QUOTES, 'UTF-8') . '</a>',
|
||||
$escaped
|
||||
) ?? $escaped;
|
||||
$html .= '<p>' . nl2br($escaped) . '</p>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
function app_record_legal_acceptance(
|
||||
PDO $pdo,
|
||||
?int $tenantId,
|
||||
?int $userId,
|
||||
string $documentType,
|
||||
string $context,
|
||||
array $metadata = []
|
||||
): void {
|
||||
$evidence = [
|
||||
'document_sha256' => hash('sha256', app_legal_document_text($documentType)),
|
||||
];
|
||||
if ($tenantId !== null) {
|
||||
$snapshotStmt = $pdo->prepare('SELECT slug, name FROM tenants WHERE id = ?');
|
||||
$snapshotStmt->execute([$tenantId]);
|
||||
$tenantSnapshot = $snapshotStmt->fetch();
|
||||
if ($tenantSnapshot !== false) {
|
||||
$evidence['tenant_slug'] = (string)$tenantSnapshot['slug'];
|
||||
$evidence['tenant_name'] = (string)$tenantSnapshot['name'];
|
||||
}
|
||||
}
|
||||
if ($userId !== null) {
|
||||
$snapshotStmt = $pdo->prepare('SELECT email_norm FROM users WHERE id = ?');
|
||||
$snapshotStmt->execute([$userId]);
|
||||
$userEmail = $snapshotStmt->fetchColumn();
|
||||
if ($userEmail !== false) {
|
||||
$evidence['user_email'] = (string)$userEmail;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO legal_acceptances
|
||||
(tenant_id, user_id, document_type, document_version, context, ip, user_agent, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
$userId,
|
||||
$documentType,
|
||||
app_legal_document_version($documentType),
|
||||
$context,
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
|
||||
json_encode(
|
||||
$evidence + $metadata,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet einen Vertrag nur dann automatisch, wenn Kundenkürzel und die
|
||||
* E-Mail eines aktiven Inhabers zusammenpassen. Die Erklärung wird auch bei
|
||||
* fehlender Zuordnung gespeichert: Eine fehlerhafte Referenz darf nicht zum
|
||||
* Verlust einer fristgebundenen Kündigung oder eines Widerrufs führen.
|
||||
*
|
||||
* @return array{tenant_id: ?int, user_id: ?int, customer_type: ?string}
|
||||
*/
|
||||
function app_match_legal_contract(PDO $pdo, string $contractReference, string $email): array
|
||||
{
|
||||
$slug = strtolower(trim($contractReference));
|
||||
$emailNorm = strtolower(trim($email));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id AS tenant_id, t.customer_type, u.id AS user_id
|
||||
FROM tenants t
|
||||
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
|
||||
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
|
||||
WHERE t.slug = ? AND u.email_norm = ?
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$slug, $emailNorm]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row === false) {
|
||||
return ['tenant_id' => null, 'user_id' => null, 'customer_type' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'tenant_id' => (int)$row['tenant_id'],
|
||||
'user_id' => (int)$row['user_id'],
|
||||
'customer_type' => (string)$row['customer_type'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{reference_code: string, access_token: string, received_at: string, tenant_id: ?int, user_id: ?int}
|
||||
*/
|
||||
function app_store_legal_request(PDO $pdo, array $input): array
|
||||
{
|
||||
$match = app_match_legal_contract(
|
||||
$pdo,
|
||||
(string)$input['contract_reference'],
|
||||
(string)$input['requester_email']
|
||||
);
|
||||
$accessToken = bin2hex(random_bytes(32));
|
||||
$referenceCode = 'KL-' . strtoupper(bin2hex(random_bytes(6)));
|
||||
$receivedAt = date('Y-m-d H:i:s');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO legal_requests
|
||||
(reference_code, access_token_hash, request_type, tenant_id, user_id,
|
||||
requester_name, requester_email, contract_reference, request_reason,
|
||||
requested_end, status, ip, user_agent, metadata_json, received_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$referenceCode,
|
||||
hash('sha256', $accessToken),
|
||||
(string)$input['request_type'],
|
||||
$match['tenant_id'],
|
||||
$match['user_id'],
|
||||
(string)$input['requester_name'],
|
||||
(string)$input['requester_email'],
|
||||
(string)$input['contract_reference'],
|
||||
($input['request_reason'] ?? '') !== '' ? (string)$input['request_reason'] : null,
|
||||
($input['requested_end'] ?? '') !== '' ? (string)$input['requested_end'] : null,
|
||||
'received',
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
|
||||
isset($input['metadata']) ? json_encode($input['metadata'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
$receivedAt,
|
||||
]);
|
||||
|
||||
return [
|
||||
'reference_code' => $referenceCode,
|
||||
'access_token' => $accessToken,
|
||||
'received_at' => $receivedAt,
|
||||
'tenant_id' => $match['tenant_id'],
|
||||
'user_id' => $match['user_id'],
|
||||
];
|
||||
}
|
||||
|
||||
function app_fetch_legal_request_by_token(PDO $pdo, string $token): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT reference_code, request_type, requester_name, requester_email,
|
||||
contract_reference, request_reason, requested_end, status, metadata_json, received_at
|
||||
FROM legal_requests WHERE access_token_hash = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([hash('sha256', $token)]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row !== false ? $row : null;
|
||||
}
|
||||
+3
-4
@@ -20,10 +20,9 @@ function app_rate_limit_check(PDO $pdo, string $bucket, int $maxAttempts, int $w
|
||||
{
|
||||
$pdo->prepare('INSERT INTO rate_limit_attempts (bucket) VALUES (?)')->execute([$bucket]);
|
||||
|
||||
// Opportunistic cleanup so the table doesn't grow unbounded; scoped to
|
||||
// this bucket to keep each call cheap.
|
||||
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ? AND created_at < DATE_SUB(NOW(), INTERVAL ? SECOND)')
|
||||
->execute([$bucket, $windowSeconds]);
|
||||
// Globaler Maximalhorizont: unabhängig davon, ob derselbe Bucket je wieder
|
||||
// benutzt wird, bleibt keine IP-basierte Rate-Limit-Zeile länger als 24h.
|
||||
$pdo->exec('DELETE FROM rate_limit_attempts WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM rate_limit_attempts WHERE bucket = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)'
|
||||
|
||||
+42
-8
@@ -6,6 +6,7 @@ require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/database.php';
|
||||
require_once __DIR__ . '/faq.php';
|
||||
require_once __DIR__ . '/billing.php';
|
||||
require_once __DIR__ . '/legal.php';
|
||||
|
||||
function saas_email_norm(string $email): string
|
||||
{
|
||||
@@ -55,7 +56,7 @@ function saas_normalize_host(?string $host): string
|
||||
|
||||
function saas_primary_app_host(): string
|
||||
{
|
||||
return saas_normalize_host(app_env('APP_PRIMARY_HOST', ''));
|
||||
return saas_normalize_host(app_env('APP_PRIMARY_HOST', app_primary_host() ?? ''));
|
||||
}
|
||||
|
||||
function saas_is_primary_app_host(?string $host = null): bool
|
||||
@@ -92,6 +93,7 @@ function saas_resolve_tenant_domain(PDO $pdo, ?string $host = null): ?array
|
||||
WHERE td.domain_norm = ?
|
||||
AND td.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$host, 'active', 'active']);
|
||||
@@ -166,6 +168,8 @@ function saas_current_user(?PDO $pdo = null): ?array
|
||||
t.id AS tenant_id,
|
||||
t.slug AS tenant_slug,
|
||||
t.name AS tenant_name,
|
||||
t.customer_type,
|
||||
t.contract_ends_at,
|
||||
t.status AS tenant_status,
|
||||
tm.role,
|
||||
tm.status AS membership_status
|
||||
@@ -177,6 +181,7 @@ function saas_current_user(?PDO $pdo = null): ?array
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
||||
@@ -264,6 +269,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
t.id AS tenant_id,
|
||||
t.slug,
|
||||
t.name,
|
||||
t.customer_type,
|
||||
t.status,
|
||||
t.timezone,
|
||||
t.locale,
|
||||
@@ -312,6 +318,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
'tenant_id' => (int)$settings['tenant_id'],
|
||||
'slug' => (string)$settings['slug'],
|
||||
'name' => (string)$settings['name'],
|
||||
'customer_type' => (string)$settings['customer_type'],
|
||||
'status' => (string)$settings['status'],
|
||||
'timezone' => (string)$settings['timezone'],
|
||||
'locale' => (string)$settings['locale'],
|
||||
@@ -344,6 +351,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): array
|
||||
{
|
||||
$tenantName = trim((string)($input['tenant_name'] ?? ''));
|
||||
$customerType = (string)($input['customer_type'] ?? '');
|
||||
$timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin'));
|
||||
$locale = trim((string)($input['locale'] ?? 'de-DE'));
|
||||
$currencyCode = strtoupper(trim((string)($input['currency_code'] ?? 'EUR')));
|
||||
@@ -370,6 +378,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
|
||||
}
|
||||
if (!in_array($customerType, ['business', 'consumer'], true)) {
|
||||
$errors[] = 'Bitte gib an, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.';
|
||||
}
|
||||
if (!in_array($timezone, DateTimeZone::listIdentifiers(), true)) {
|
||||
$errors[] = 'Die Zeitzone ist ungültig.';
|
||||
}
|
||||
@@ -442,10 +453,10 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE tenants
|
||||
SET name = ?, timezone = ?, locale = ?, currency_code = ?
|
||||
SET name = ?, customer_type = ?, timezone = ?, locale = ?, currency_code = ?
|
||||
WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$tenantName, $timezone, $locale, $currencyCode, $tenantId]);
|
||||
$stmt->execute([$tenantName, $customerType, $timezone, $locale, $currencyCode, $tenantId]);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenant_settings
|
||||
@@ -554,7 +565,8 @@ function saas_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = '
|
||||
WHERE u.email_norm = ?
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?' . $tenantFilter . '
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . '
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
@@ -593,6 +605,7 @@ function saas_list_user_memberships(PDO $pdo, int $userId): array
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
@@ -630,6 +643,7 @@ function saas_identity_for_user_tenant(PDO $pdo, int $userId, int $tenantId): ?a
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
||||
@@ -1021,7 +1035,8 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?' . $tenantFilter . '
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . '
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
@@ -1206,6 +1221,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
$emailNorm = saas_email_norm($email);
|
||||
$password = (string)($input['password'] ?? '');
|
||||
$passwordConfirm = (string)($input['password_confirm'] ?? '');
|
||||
$customerType = (string)($input['customer_type'] ?? '');
|
||||
|
||||
$errors = [];
|
||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||
@@ -1226,6 +1242,18 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
if ($password !== $passwordConfirm) {
|
||||
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
|
||||
}
|
||||
if (!in_array($customerType, ['business', 'consumer'], true)) {
|
||||
$errors[] = 'Bitte gib an, ob du als Unternehmen oder als Verbraucher handelst.';
|
||||
}
|
||||
if (empty($input['accept_terms'])) {
|
||||
$errors[] = 'Bitte akzeptiere die AGB.';
|
||||
}
|
||||
if (empty($input['acknowledge_privacy'])) {
|
||||
$errors[] = 'Bitte bestätige, dass du die Datenschutzerklärung zur Kenntnis genommen hast.';
|
||||
}
|
||||
if (empty($input['accept_dpa'])) {
|
||||
$errors[] = 'Bitte vereinbare den AVV für den Fall, dass du Daten anderer Personen verwaltest.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
@@ -1265,10 +1293,10 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO tenants (slug, name, customer_type, status, timezone, locale, currency_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$tenantSlug, $tenantName, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
|
||||
$stmt->execute([$tenantSlug, $tenantName, $customerType, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
|
||||
$tenantId = (int)$pdo->lastInsertId();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
@@ -1311,6 +1339,11 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
);
|
||||
$stmt->execute([$tenantId, $userId, $displayName, $email, $emailNorm, 1]);
|
||||
|
||||
$acceptanceMetadata = ['customer_type' => $customerType];
|
||||
app_record_legal_acceptance($pdo, $tenantId, $userId, 'terms', 'registration', $acceptanceMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, $userId, 'privacy_notice', 'registration', $acceptanceMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, $userId, 'dpa', 'registration', $acceptanceMetadata);
|
||||
|
||||
$verificationToken = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440);
|
||||
|
||||
$pdo->commit();
|
||||
@@ -1335,6 +1368,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_slug' => $tenantSlug,
|
||||
'tenant_name' => $tenantName,
|
||||
'customer_type' => $customerType,
|
||||
'role' => 'owner',
|
||||
],
|
||||
'email_verification_token' => $verificationToken['token'],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/legal.php';
|
||||
|
||||
function saas_mail_transport(): string
|
||||
{
|
||||
@@ -172,6 +173,7 @@ function saas_log_outbound_email(
|
||||
?string $error,
|
||||
?int $createdByUserId
|
||||
): int {
|
||||
$pdo->exec('DELETE FROM outbound_emails WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO outbound_emails
|
||||
(tenant_id, participant_id, template, subject, status, sent_at, error, created_by_user_id)
|
||||
@@ -267,6 +269,71 @@ function saas_send_email_verification_mail(string $to, string $token): array
|
||||
return saas_send_mail($to, 'Kaffeeliste E-Mail bestätigen', $body);
|
||||
}
|
||||
|
||||
function saas_send_registration_contract_mail(string $to, string $tenantName, string $tenantSlug, string $customerType): array
|
||||
{
|
||||
$body = implode("\n", [
|
||||
'Vertragsbestätigung Kaffeeliste',
|
||||
'',
|
||||
'Kunde: ' . $tenantName,
|
||||
'Kundenkürzel: ' . $tenantSlug,
|
||||
'Kundentyp: ' . ($customerType === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
|
||||
'Tarif: Kostenlos, 0,00 € pro Monat, unbefristet, jederzeit kündbar',
|
||||
'Vertragsschluss: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
|
||||
'',
|
||||
'Nachfolgend erhältst du die bei Vertragsschluss geltenden Texte auf einem dauerhaften Datenträger.',
|
||||
'',
|
||||
'================ AGB ================',
|
||||
app_legal_document_text('terms'),
|
||||
'',
|
||||
'================ AVV ================',
|
||||
app_legal_document_text('dpa'),
|
||||
'',
|
||||
'========== WIDERRUFSBELEHRUNG ==========',
|
||||
app_legal_document_text('withdrawal_information'),
|
||||
]);
|
||||
|
||||
return saas_send_mail($to, 'Kaffeeliste Vertragsbestätigung – kostenloser Tarif', $body);
|
||||
}
|
||||
|
||||
function saas_send_paid_contract_confirmation(PDO $pdo, int $tenantId, string $planCode): array
|
||||
{
|
||||
require_once __DIR__ . '/billing.php';
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.name, t.slug, t.customer_type, u.email
|
||||
FROM tenants t
|
||||
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
|
||||
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
|
||||
WHERE t.id = ? ORDER BY tm.id LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$contract = $stmt->fetch();
|
||||
$plan = billing_plans()[$planCode] ?? null;
|
||||
if ($contract === false || $plan === null) {
|
||||
return ['ok' => false, 'transport' => saas_mail_transport(), 'error' => 'contract confirmation data missing'];
|
||||
}
|
||||
|
||||
$body = implode("\n", [
|
||||
'Vertragsbestätigung Kaffeeliste – kostenpflichtiger Tarif',
|
||||
'',
|
||||
'Kunde: ' . $contract['name'],
|
||||
'Kundenkürzel: ' . $contract['slug'],
|
||||
'Kundentyp: ' . ($contract['customer_type'] === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
|
||||
'Tarif: ' . $plan['label'],
|
||||
'Gesamtpreis: ' . number_format(((int)$plan['price_cents']) / 100, 2, ',', '') . ' € pro Monat',
|
||||
'Laufzeit: unbefristet; monatliche Abrechnung; keine längere Mindestlaufzeit',
|
||||
'Kündigung: jederzeit zum Ende der laufenden Abrechnungsperiode',
|
||||
'Bestätigung: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
|
||||
'',
|
||||
'================ AGB ================',
|
||||
app_legal_document_text('terms'),
|
||||
'',
|
||||
'========== WIDERRUFSBELEHRUNG ==========',
|
||||
app_legal_document_text('withdrawal_information'),
|
||||
]);
|
||||
|
||||
return saas_send_mail((string)$contract['email'], 'Kaffeeliste Vertragsbestätigung – ' . $plan['label'], $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Textkoerper der automatischen Zahlungserinnerung an ein Mitglied mit
|
||||
* offenem Betrag oberhalb der Warnschwelle des Mandanten.
|
||||
|
||||
+44
-6
@@ -151,6 +151,12 @@ function stripe_create_checkout_session(string $priceId, string $customerEmail,
|
||||
// .deleted-Events liefern kein Checkout-Session-Objekt, aber die
|
||||
// Metadaten der Subscription, darueber loesen wir tenant_id auf.
|
||||
'subscription_data' => ['metadata' => $metadata],
|
||||
'locale' => 'de',
|
||||
'custom_text' => [
|
||||
'submit' => [
|
||||
'message' => 'Monatlich kündbar zum Ende der laufenden Abrechnungsperiode. Es gelten die vorab bestätigten AGB und die Widerrufsbelehrung.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if ($existingCustomerId !== null) {
|
||||
@@ -175,6 +181,17 @@ function stripe_create_billing_portal_session(string $customerId, string $return
|
||||
$result = stripe_request('POST', 'billing_portal/sessions', [
|
||||
'customer' => $customerId,
|
||||
'return_url' => $returnUrl,
|
||||
'locale' => 'de',
|
||||
// Als gezielter Stripe-Flow sind Navigation und andere Portalaktionen
|
||||
// ausgeblendet. Tarifwechsel und Kündigung bleiben damit in unseren
|
||||
// rechtlich dokumentierten Bestell- bzw. Kündigungsabläufen.
|
||||
'flow_data' => [
|
||||
'type' => 'payment_method_update',
|
||||
'after_completion' => [
|
||||
'type' => 'redirect',
|
||||
'redirect' => ['return_url' => $returnUrl],
|
||||
],
|
||||
],
|
||||
]);
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'url' => null, 'error' => $result['error']];
|
||||
@@ -247,9 +264,9 @@ function stripe_get_subscription(string $subscriptionId): array
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches an existing active subscription to a different Price in place
|
||||
* (upgrade or downgrade between paid tiers), instead of creating a brand
|
||||
* new Checkout Session. Stripe prorates the difference automatically.
|
||||
* Switches an existing active subscription to a different Price in place.
|
||||
* The billing cycle stays unchanged and no unclear mid-cycle debit/credit is
|
||||
* created; the new full monthly amount applies at the next renewal.
|
||||
* Also clears any pending cancel_at_period_end, since choosing a plan is
|
||||
* an explicit signal the tenant wants to keep the subscription running.
|
||||
*
|
||||
@@ -259,7 +276,7 @@ function stripe_update_subscription_price(string $subscriptionId, string $subscr
|
||||
{
|
||||
$result = stripe_request('POST', "subscriptions/{$subscriptionId}", [
|
||||
'items' => [['id' => $subscriptionItemId, 'price' => $newPriceId]],
|
||||
'proration_behavior' => 'create_prorations',
|
||||
'proration_behavior' => 'none',
|
||||
'cancel_at_period_end' => 'false',
|
||||
]);
|
||||
|
||||
@@ -271,8 +288,10 @@ function stripe_update_subscription_price(string $subscriptionId, string $subscr
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a subscription immediately (not at period end) - used when a
|
||||
* tenant downgrades all the way to the free plan from the admin UI.
|
||||
* Cancels a subscription immediately. This is reserved for exceptional
|
||||
* workflows such as a confirmed withdrawal; ordinary cancellations use
|
||||
* stripe_schedule_subscription_cancellation() so paid access remains until
|
||||
* the end of the already paid billing period.
|
||||
*
|
||||
* @return array{ok: bool, error: ?string}
|
||||
*/
|
||||
@@ -286,6 +305,25 @@ function stripe_cancel_subscription(string $subscriptionId): array
|
||||
return ['ok' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, current_period_end: ?string, error: ?string}
|
||||
*/
|
||||
function stripe_schedule_subscription_cancellation(string $subscriptionId): array
|
||||
{
|
||||
$result = stripe_request('POST', "subscriptions/{$subscriptionId}", [
|
||||
'cancel_at_period_end' => 'true',
|
||||
]);
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'current_period_end' => null, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
$periodEnd = isset($result['data']['current_period_end'])
|
||||
? date('Y-m-d H:i:s', (int)$result['data']['current_period_end'])
|
||||
: null;
|
||||
|
||||
return ['ok' => true, 'current_period_end' => $periodEnd, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, type: string, data: array}|null
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user