diff --git a/app/saas-auth.php b/app/saas-auth.php index f73fb24..4926dba 100644 --- a/app/saas-auth.php +++ b/app/saas-auth.php @@ -24,6 +24,40 @@ function saas_html(mixed $value): string return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); } +function saas_parse_money_cents(mixed $value): ?int +{ + $normalized = trim((string)$value); + $normalized = str_replace(["\xc2\xa0", ' '], '', $normalized); + + if ($normalized === '') { + return null; + } + + if (str_contains($normalized, ',') && str_contains($normalized, '.')) { + $normalized = str_replace('.', '', $normalized); + } + + $normalized = str_replace(',', '.', $normalized); + if (!preg_match('/^-?[0-9]+(?:\.[0-9]{1,2})?$/', $normalized)) { + return null; + } + + $negative = str_starts_with($normalized, '-'); + if ($negative) { + $normalized = substr($normalized, 1); + } + + [$euros, $cents] = array_pad(explode('.', $normalized, 2), 2, '0'); + $result = ((int)$euros * 100) + (int)str_pad(substr($cents, 0, 2), 2, '0'); + + return $negative ? -$result : $result; +} + +function saas_format_money_cents(mixed $cents): string +{ + return number_format(((int)$cents) / 100, 2, ',', ''); +} + function saas_current_user(?PDO $pdo = null): ?array { static $cached = false; @@ -111,6 +145,182 @@ function saas_require_login(): array exit; } +function saas_user_has_role(array|string $roles, ?array $user = null): bool +{ + $user = $user ?? saas_current_user(); + if ($user === null) { + return false; + } + + $allowed = is_array($roles) ? $roles : [$roles]; + + return in_array((string)($user['role'] ?? ''), $allowed, true); +} + +function saas_require_role(array|string $roles): array +{ + $user = saas_require_login(); + if (saas_user_has_role($roles, $user)) { + return $user; + } + + http_response_code(403); + die('Keine Berechtigung.'); +} + +function saas_can_manage_tenant_settings(?array $user = null): bool +{ + return saas_user_has_role(['owner', 'admin'], $user); +} + +function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array +{ + $stmt = $pdo->prepare( + 'SELECT + t.id AS tenant_id, + t.slug, + t.name, + t.status, + t.timezone, + t.locale, + t.currency_code, + ts.mark_price_cents, + ts.self_entry_enabled, + ts.paypal_enabled, + ts.paypal_url_template, + ts.sheet_window_days, + ts.negative_warning_cents + FROM tenants t + LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id + WHERE t.id = ? + LIMIT 1' + ); + $stmt->execute([$tenantId]); + $settings = $stmt->fetch(); + + if ($settings === false) { + return null; + } + + if ($settings['mark_price_cents'] === null) { + $pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$tenantId]); + + return saas_fetch_tenant_settings($pdo, $tenantId); + } + + return [ + 'tenant_id' => (int)$settings['tenant_id'], + 'slug' => (string)$settings['slug'], + 'name' => (string)$settings['name'], + 'status' => (string)$settings['status'], + 'timezone' => (string)$settings['timezone'], + 'locale' => (string)$settings['locale'], + 'currency_code' => (string)$settings['currency_code'], + 'mark_price_cents' => (int)$settings['mark_price_cents'], + 'self_entry_enabled' => (int)$settings['self_entry_enabled'], + 'paypal_enabled' => (int)$settings['paypal_enabled'], + 'paypal_url_template' => (string)$settings['paypal_url_template'], + 'sheet_window_days' => (int)$settings['sheet_window_days'], + 'negative_warning_cents' => (int)$settings['negative_warning_cents'], + ]; +} + +function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): array +{ + $tenantName = trim((string)($input['tenant_name'] ?? '')); + $timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin')); + $locale = trim((string)($input['locale'] ?? 'de-DE')); + $currencyCode = strtoupper(trim((string)($input['currency_code'] ?? 'EUR'))); + $markPriceCents = saas_parse_money_cents($input['mark_price'] ?? ''); + $sheetWindowDays = filter_var($input['sheet_window_days'] ?? null, FILTER_VALIDATE_INT); + $negativeWarningCents = saas_parse_money_cents($input['negative_warning'] ?? ''); + $paypalUrlTemplate = trim((string)($input['paypal_url_template'] ?? '')); + + $errors = []; + if (strlen($tenantName) < 3 || strlen($tenantName) > 255) { + $errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.'; + } + if (!in_array($timezone, DateTimeZone::listIdentifiers(), true)) { + $errors[] = 'Die Zeitzone ist ungueltig.'; + } + if (!preg_match('/^[a-z]{2}-[A-Z]{2}$/', $locale)) { + $errors[] = 'Die Locale muss dem Muster de-DE entsprechen.'; + } + if (!preg_match('/^[A-Z]{3}$/', $currencyCode)) { + $errors[] = 'Der Waehrungscode muss aus drei Grossbuchstaben bestehen.'; + } + if ($markPriceCents === null || $markPriceCents < 1 || $markPriceCents > 10000) { + $errors[] = 'Der Preis pro Strich muss zwischen 0,01 und 100,00 liegen.'; + } + if ($sheetWindowDays === false || $sheetWindowDays < 1 || $sheetWindowDays > 365) { + $errors[] = 'Das Listenfenster muss zwischen 1 und 365 Tagen liegen.'; + } + if ($negativeWarningCents === null || $negativeWarningCents < 0 || $negativeWarningCents > 100000) { + $errors[] = 'Die Schulden-Warnschwelle muss zwischen 0,00 und 1000,00 liegen.'; + } + if (strlen($paypalUrlTemplate) > 1000) { + $errors[] = 'Der PayPal-Link ist zu lang.'; + } + + if ($errors !== []) { + return ['ok' => false, 'errors' => $errors]; + } + + $selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0; + $paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0; + + try { + $pdo->beginTransaction(); + + $stmt = $pdo->prepare( + 'UPDATE tenants + SET name = ?, timezone = ?, locale = ?, currency_code = ? + WHERE id = ?' + ); + $stmt->execute([$tenantName, $timezone, $locale, $currencyCode, $tenantId]); + + $stmt = $pdo->prepare( + 'INSERT INTO tenant_settings + (tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled, + paypal_url_template, sheet_window_days, negative_warning_cents) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + mark_price_cents = VALUES(mark_price_cents), + self_entry_enabled = VALUES(self_entry_enabled), + paypal_enabled = VALUES(paypal_enabled), + paypal_url_template = VALUES(paypal_url_template), + sheet_window_days = VALUES(sheet_window_days), + negative_warning_cents = VALUES(negative_warning_cents)' + ); + $stmt->execute([ + $tenantId, + $markPriceCents, + $selfEntryEnabled, + $paypalEnabled, + $paypalUrlTemplate, + (int)$sheetWindowDays, + -abs($negativeWarningCents), + ]); + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return [ + 'ok' => false, + 'errors' => ['Die Einstellungen konnten nicht gespeichert werden.'], + ]; + } + + return [ + 'ok' => true, + 'errors' => [], + 'settings' => saas_fetch_tenant_settings($pdo, $tenantId), + ]; +} + function saas_authenticate(PDO $pdo, string $email, string $password, string $tenantSlug = ''): array { $emailNorm = saas_email_norm($email); diff --git a/docs/m2-technical-foundation.md b/docs/m2-technical-foundation.md index 9c825ac..ae25824 100644 --- a/docs/m2-technical-foundation.md +++ b/docs/m2-technical-foundation.md @@ -140,8 +140,8 @@ Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`, eine PayPal-Alias-Dublette und hinterlaesst keine Datei in `var/uploads`. - CSV-Upload negative Tests: Nicht-CSV-Dateien werden abgewiesen. - Golden Master weiterhin gruen mit 104 Assertions. -- HTTP-Smoke weiterhin gruen mit 16 sicheren Seiten inklusive Login und - Registrierung. +- HTTP-Smoke weiterhin gruen mit 17 sicheren Seiten inklusive Login, + Registrierung und geschuetzter Mandant-Einstellungen. ## Bewusste Grenzen diff --git a/docs/m3-saas-basis-vorbereitung.md b/docs/m3-saas-basis-vorbereitung.md index 2c8c0fe..b4eefb7 100644 --- a/docs/m3-saas-basis-vorbereitung.md +++ b/docs/m3-saas-basis-vorbereitung.md @@ -47,6 +47,7 @@ Ergaenzende Skripte: scripts/backfill-default-tenant.php scripts/check-m3-saas-basis.php scripts/check-m3-auth-flow.php +scripts/check-m3-settings-flow.php ``` Wichtige Regeln: @@ -136,8 +137,27 @@ Noch offen im M3-Auth-Scope: - Passwort-Reset. - E-Mail-Verifikation. - Tenant-Aufloesung ueber Subdomain oder Custom Domain. -- Zentrale Rollenpruefung fuer neue SaaS-Seiten. -- Owner-/Admin-Seite fuer Tenant-Grundeinstellungen. +- Weitergehende Rollenmatrix fuer spaetere SaaS-Seiten. + +## Rollen und Grundeinstellungen + +Umgesetzte Dateien: + +```text +mandant-einstellungen.php +``` + +Umgesetzter Umfang: + +- `saas_user_has_role()`, `saas_require_role()` und + `saas_can_manage_tenant_settings()` zentralisieren die erste Rollenpruefung. +- Owner und Admin duerfen Tenant-Grundeinstellungen bearbeiten. +- Die Seite bearbeitet Kundenname, Zeitzone, Locale, Waehrung, Preis pro + Strich, Web-Striche, PayPal-Link, Listenfenster und Schulden-Warnschwelle. +- Geldwerte werden in der UI als Euro-Werte erfasst und weiterhin als Cent- + Integer gespeichert. +- `konto.php` und die Sidebar verlinken die Einstellungen nur fuer passende + Rollen. ## Erste Akzeptanzkriterien @@ -150,6 +170,7 @@ Noch offen im M3-Auth-Scope: - Admins werden als tenant-scoped Rolle abgebildet: erfuellt. - Registrierung und Login funktionieren fuer einen neuen Test-Tenant: erfuellt. +- Owner-/Admin-Grundeinstellungen koennen aktualisiert werden: erfuellt. - Golden-Master und HTTP-Smoke bleiben gruen: erfuellt. ## Risiken @@ -171,6 +192,8 @@ Noch offen im M3-Auth-Scope: 5. Kontrollskript fuer Tenant/Participant/Role-Counts schreiben: erledigt. 6. Login-/Registrierungsrouten bauen: erledigt. 7. Auth-Flow-Kontrollskript schreiben: erledigt. -8. Golden-Master und HTTP-Smoke ausfuehren: erledigt. -9. Passwort-Reset, E-Mail-Verifikation und zentrale Rollenpruefung planen: +8. Zentrale Rollenpruefung und Mandant-Einstellungen bauen: erledigt. +9. Settings-Flow-Kontrollskript schreiben: erledigt. +10. Golden-Master und HTTP-Smoke ausfuehren: erledigt. +11. Passwort-Reset, E-Mail-Verifikation und Tenant-Aufloesung planen: naechster Schritt. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 7871a42..1171e4c 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -422,8 +422,8 @@ Schritte: - Login und Logout bauen: erster Flow erledigt. - Passwort-Reset und E-Mail-Verifikation bauen. - Tenant-Aufloesung definieren. -- Rollenpruefung zentralisieren. -- Erste Admin-/Owner-Seite fuer Grundeinstellungen. +- Rollenpruefung zentralisieren: erster Owner/Admin-Check erledigt. +- Erste Admin-/Owner-Seite fuer Grundeinstellungen: erledigt. Ergebnis: diff --git a/footer.php b/footer.php index f3adca5..d2e153a 100644 --- a/footer.php +++ b/footer.php @@ -38,6 +38,13 @@ $saasNavUser = function_exists('saas_current_user') ? saas_current_user() : null if ($saasNavUser !== null) { ?>