Schalter fuer den Hinweis auf die Kaffeeliste

Der Hinweis unten rechts laesst sich ab einem bezahlten Tarif in den
Mandant-Einstellungen abschalten (neue Spalte tenant_settings.
watermark_enabled, Standard an). Im kostenlosen Tarif bleibt er fest
eingeschaltet: das Kaestchen ist angehakt und disabled, daneben steht der
Grund. Zusaetzlich erzwingt saas_update_tenant_settings() den Wert dort noch
einmal, damit ein nachgebauter POST ihn nicht abschalten kann.

Der Titel des Wasserzeichens spricht nicht mehr vom kostenlosen Tarif -
zahlende Mandanten koennen es jetzt freiwillig zeigen.

Der Smoke-Test prueft die Einstellungsseite jetzt auch angemeldet. Sein
Muster fuer PHP-Meldungen verlangt dafuer einen Doppelpunkt, sonst haette
das Feld "negative_warning" als Warnung gezaehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 00:27:44 +02:00
co-authored by Claude Opus 5
parent 1d53b66e3f
commit b244451c42
8 changed files with 153 additions and 16 deletions
+18 -4
View File
@@ -44,7 +44,10 @@ function plan_watermark_plan_code(PDO $pdo, int $tenantId): string
} }
/** /**
* Zeigt dieser Mandant das Wasserzeichen? Nur der kostenlose Tarif tut das. * Zeigt dieser Mandant das Wasserzeichen? Im kostenlosen Tarif immer - es ist
* der Gegenwert fuer die kostenlose Nutzung und deshalb nicht abschaltbar.
* Alle anderen entscheiden ueber die Mandant-Einstellung
* `watermark_enabled` (Standard: an).
*/ */
function plan_watermark_visible(PDO $pdo, int $tenantId): bool function plan_watermark_visible(PDO $pdo, int $tenantId): bool
{ {
@@ -52,7 +55,18 @@ function plan_watermark_visible(PDO $pdo, int $tenantId): bool
return false; return false;
} }
return plan_watermark_plan_code($pdo, $tenantId) === 'free'; $planCode = plan_watermark_plan_code($pdo, $tenantId);
if ($planCode === 'free') {
return true;
}
if ($planCode === 'unknown') {
return false;
}
require_once __DIR__ . '/features.php';
return app_tenant_switch_enabled($pdo, $tenantId, 'watermark_enabled');
} }
/** /**
@@ -84,10 +98,10 @@ function plan_watermark_html(): string
. '<span class="plan-watermark-text">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>'; . '<span class="plan-watermark-text">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>';
if ($url === '') { if ($url === '') {
return '<div class="plan-watermark" title="Kostenloser Tarif der Kaffeeliste">' . $inner . '</div>'; return '<div class="plan-watermark" title="Kaffeeliste die digitale Kaffeekasse">' . $inner . '</div>';
} }
return '<a class="plan-watermark" href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '"' return '<a class="plan-watermark" href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '"'
. ' target="_blank" rel="noopener noreferrer"' . ' target="_blank" rel="noopener noreferrer"'
. ' title="Diese Kaffeeliste läuft im kostenlosen Tarif">' . $inner . '</a>'; . ' title="Kaffeeliste die digitale Kaffeekasse">' . $inner . '</a>';
} }
+18 -4
View File
@@ -288,7 +288,8 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
ts.bank_account_holder, ts.bank_account_holder,
ts.bank_iban, ts.bank_iban,
ts.brand_logo, ts.brand_logo,
ts.brand_color ts.brand_color,
ts.watermark_enabled
FROM tenants t FROM tenants t
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
WHERE t.id = ? WHERE t.id = ?
@@ -336,6 +337,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
'bank_iban' => (string)$settings['bank_iban'], 'bank_iban' => (string)$settings['bank_iban'],
'brand_logo' => (string)$settings['brand_logo'], 'brand_logo' => (string)$settings['brand_logo'],
'brand_color' => (string)$settings['brand_color'], 'brand_color' => (string)$settings['brand_color'],
'watermark_enabled' => (int)$settings['watermark_enabled'],
]; ];
} }
@@ -425,6 +427,16 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0; $pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
$paymentReminderEnabled = !empty($input['payment_reminder_enabled']) ? 1 : 0; $paymentReminderEnabled = !empty($input['payment_reminder_enabled']) ? 1 : 0;
// Der Hinweis auf die Kaffeeliste laesst sich abschalten - ausser im
// kostenlosen Tarif. Die Pruefung steht hier und nicht nur im Formular,
// damit auch ein nachgebauter POST das Kaestchen nicht leeren kann. Vor
// der Transaktion, weil billing_fetch_or_init() eine fehlende Zeile
// anlegt.
$watermarkEnabled = !empty($input['watermark_enabled']) ? 1 : 0;
if (billing_fetch_or_init($pdo, $tenantId)['plan_code'] === 'free') {
$watermarkEnabled = 1;
}
try { try {
$pdo->beginTransaction(); $pdo->beginTransaction();
@@ -443,8 +455,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
pdf_watermark_text, pdf_footer_text, pdf_watermark_text, pdf_footer_text,
payment_reminder_enabled, payment_reminder_interval_days, payment_reminder_enabled, payment_reminder_interval_days,
cash_enabled, cash_contact, bank_transfer_enabled, cash_enabled, cash_contact, bank_transfer_enabled,
bank_account_holder, bank_iban, brand_color) bank_account_holder, bank_iban, brand_color, watermark_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
mark_price_cents = VALUES(mark_price_cents), mark_price_cents = VALUES(mark_price_cents),
self_entry_enabled = VALUES(self_entry_enabled), self_entry_enabled = VALUES(self_entry_enabled),
@@ -464,7 +476,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
bank_transfer_enabled = VALUES(bank_transfer_enabled), bank_transfer_enabled = VALUES(bank_transfer_enabled),
bank_account_holder = VALUES(bank_account_holder), bank_account_holder = VALUES(bank_account_holder),
bank_iban = VALUES(bank_iban), bank_iban = VALUES(bank_iban),
brand_color = VALUES(brand_color)' brand_color = VALUES(brand_color),
watermark_enabled = VALUES(watermark_enabled)'
); );
$stmt->execute([ $stmt->execute([
$tenantId, $tenantId,
@@ -487,6 +500,7 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
$bankAccountHolder, $bankAccountHolder,
$bankIban, $bankIban,
$brandColor, $brandColor,
$watermarkEnabled,
]); ]);
$pdo->commit(); $pdo->commit();
+14
View File
@@ -215,3 +215,17 @@ a.plan-watermark {
display: none; display: none;
} }
} }
/* ---------------------------------------------------------------------------
* Festgestellte Kaestchen (z. B. der Hinweis auf die Kaffeeliste im
* kostenlosen Tarif)
*
* Das Theme zeichnet das Kaestchen ueber das folgende <label>; ein disabled
* gesetztes Feld saehe sonst genauso aus wie ein bedienbares. Der gedimmte
* Zustand macht sichtbar, dass hier nichts zu klicken ist - die Begruendung
* steht als Hinweis daneben.
* ------------------------------------------------------------------------ */
input[type="checkbox"]:disabled + label {
cursor: not-allowed;
opacity: 0.6;
}
@@ -0,0 +1,9 @@
-- Schalter fuer den Hinweis auf die Kaffeeliste (Logo unten rechts).
--
-- Standard ist "an": die Marke ist Teil der Oberflaeche, wer sie nicht
-- moechte, blendet sie aus. Im kostenlosen Tarif bleibt sie unabhaengig von
-- diesem Wert eingeblendet - erzwungen in saas_update_tenant_settings() und
-- app/plan-watermark.php, damit auch ein manipulierter Formular-POST sie
-- dort nicht abschalten kann.
ALTER TABLE tenant_settings
ADD COLUMN watermark_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER brand_color;
+18 -7
View File
@@ -328,27 +328,38 @@ mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-But
gegen einen echten Test-Mandanten durchspielen (Upgrade, Downgrade gegen einen echten Test-Mandanten durchspielen (Upgrade, Downgrade
zwischen zwei bezahlten Stufen, Downgrade auf `free`). zwischen zwei bezahlten Stufen, Downgrade auf `free`).
## Wasserzeichen im kostenlosen Tarif (umgesetzt) ## Hinweis auf die Kaffeeliste (Wasserzeichen, umgesetzt)
Stand: 2026-08-21. Mandanten im Tarif `free` (bis 10 Teilnehmer) zeigen Stand: 2026-08-21. Unten rechts steht eine kleine Marke mit Logo und
unten rechts eine kleine Marke mit Logo und Adresse der Kaffeeliste. Adresse der Kaffeeliste. Im Tarif `free` (bis 10 Teilnehmer) ist sie fest
Zahlende Mandanten sehen die Oberfläche ohne fremdes Logo. eingeschaltet; ab einem bezahlten Tarif entscheidet der Mandant selbst
(Mandant-Einstellungen, Standard: an).
Umgesetzte Dateien: Umgesetzte Dateien:
```text ```text
app/plan-watermark.php (Sichtbarkeit + Markup) app/plan-watermark.php (Sichtbarkeit + Markup)
app/bootstrap.php (app_marketing_url()) app/bootstrap.php (app_marketing_url())
app/saas-auth.php (Speichern, Zwang im kostenlosen Tarif)
mandant-einstellungen.php (Schalter + Hinweis)
database/migrations/0027_watermark_setting.sql (Spalte watermark_enabled)
footer.php (Einbindung vor den Skripten) footer.php (Einbindung vor den Skripten)
assets/css/app.css (.plan-watermark) assets/css/app.css (.plan-watermark)
scripts/http-smoke.php (Dashboard-Check) scripts/http-smoke.php, scripts/check-m3-settings-flow.php (Regressionstests)
``` ```
- **Nur der Tarif entscheidet.** `plan_watermark_visible()` blendet die - **Kostenlos heißt fest eingeschaltet.** `plan_watermark_visible()` zeigt
Marke ein, wenn `tenant_billing.plan_code` `free` ist fehlt der die Marke immer, wenn `tenant_billing.plan_code` `free` ist fehlt der
Datensatz, gilt wie beim Anlegen `free`. Lässt sich der Tarif nicht Datensatz, gilt wie beim Anlegen `free`. Lässt sich der Tarif nicht
lesen, bleibt die Oberfläche sauber: ein Wasserzeichen bei einem lesen, bleibt die Oberfläche sauber: ein Wasserzeichen bei einem
zahlenden Kunden wäre der schlimmere Fehler. zahlenden Kunden wäre der schlimmere Fehler.
- **Ab einem bezahlten Tarif entscheidet der Mandant.** Die Einstellung
`tenant_settings.watermark_enabled` (Standard 1) steuert die Anzeige;
der Schalter steht in den Mandant-Einstellungen. Im kostenlosen Tarif
wird das Kästchen angehakt und `disabled` dargestellt, mit einem Hinweis
daneben. Verlassen kann man sich darauf nicht `saas_update_tenant_settings()`
erzwingt den Wert im Tarif `free` noch einmal, damit auch ein
nachgebauter POST ihn nicht abschalten kann.
- **Keine Schreibzugriffe im Seitenaufbau.** Bewusst nicht - **Keine Schreibzugriffe im Seitenaufbau.** Bewusst nicht
`billing_fetch_or_init()`, das eine fehlende Zeile anlegen würde; die `billing_fetch_or_init()`, das eine fehlende Zeile anlegen würde; die
Prüfung ist eine reine Leseabfrage und wird pro Request zwischengespeichert. Prüfung ist eine reine Leseabfrage und wird pro Request zwischengespeichert.
+24
View File
@@ -90,6 +90,7 @@ if ($canManageSettings) {
'payment_reminder_interval_days' => $_POST['payment_reminder_interval_days'] ?? 7, 'payment_reminder_interval_days' => $_POST['payment_reminder_interval_days'] ?? 7,
'brand_color' => $_POST['brand_color'] ?? '', 'brand_color' => $_POST['brand_color'] ?? '',
'brand_logo' => (string)(saas_fetch_tenant_settings($pdo, (int)$user['tenant_id'])['brand_logo'] ?? ''), 'brand_logo' => (string)(saas_fetch_tenant_settings($pdo, (int)$user['tenant_id'])['brand_logo'] ?? ''),
'watermark_enabled' => !empty($_POST['watermark_enabled']) ? 1 : 0,
]; ];
} }
} else { } else {
@@ -256,6 +257,29 @@ include 'nav.php';
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php
// Der Hinweis auf die Kaffeeliste (Logo unten rechts) ist im kostenlosen
// Tarif Teil des Angebots und deshalb dort festgestellt. Das Kaestchen bleibt
// sichtbar, damit erkennbar ist, was passiert - nur eben unveraenderlich.
$watermarkErzwungen = (string)($billing['plan_code'] ?? 'free') === 'free';
$watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1) === 1;
?>
<div class="col-12">
<h3>Hinweis auf die Kaffeeliste</h3>
<p>Unten rechts steht eine kleine Marke mit dem Logo der Kaffeeliste und
einem Link auf unsere Seite.</p>
</div>
<div class="col-12">
<input type="checkbox" id="watermark_enabled" name="watermark_enabled" value="1"
<?php echo $watermarkAn ? 'checked' : ''; ?>
<?php echo $watermarkErzwungen ? 'disabled' : ''; ?>>
<label for="watermark_enabled">Logo der Kaffeeliste unten rechts anzeigen</label>
<?php if ($watermarkErzwungen): ?>
<div class="hint-box"><p>Im kostenlosen Tarif bleibt dieser Hinweis eingeblendet
er ist der Gegenwert für die kostenlose Nutzung. Ab dem Tarif „Basic" könnt ihr
ihn hier ausschalten.</p></div>
<?php endif; ?>
</div>
<div class="col-6 col-12-small"> <div class="col-6 col-12-small">
<input type="checkbox" id="payment_reminder_enabled" name="payment_reminder_enabled" value="1" <?php echo (int)$settings['payment_reminder_enabled'] === 1 ? 'checked' : ''; ?>> <input type="checkbox" id="payment_reminder_enabled" name="payment_reminder_enabled" value="1" <?php echo (int)$settings['payment_reminder_enabled'] === 1 ? 'checked' : ''; ?>>
<label for="payment_reminder_enabled">Automatische Zahlungserinnerung ab Warnschwelle</label> <label for="payment_reminder_enabled">Automatische Zahlungserinnerung ab Warnschwelle</label>
+34
View File
@@ -106,7 +106,41 @@ if ($registration['ok']) {
settings_check_assert('self entry is enabled', $updated['self_entry_enabled'] === 1, $failures, $passes); settings_check_assert('self entry is enabled', $updated['self_entry_enabled'] === 1, $failures, $passes);
settings_check_assert('pdf row height is updated', (int)$updated['pdf_row_height_px'] === 18, $failures, $passes); settings_check_assert('pdf row height is updated', (int)$updated['pdf_row_height_px'] === 18, $failures, $passes);
settings_check_assert('reminder interval is updated', (int)$updated['payment_reminder_interval_days'] === 14, $failures, $passes); settings_check_assert('reminder interval is updated', (int)$updated['payment_reminder_interval_days'] === 14, $failures, $passes);
// Das Formular oben schickt kein watermark_enabled - im kostenlosen
// Tarif bleibt der Hinweis auf die Kaffeeliste trotzdem an.
settings_check_assert('watermark stays on in the free plan', (int)$updated['watermark_enabled'] === 1, $failures, $passes);
} }
// Ab einem bezahlten Tarif entscheidet der Mandant selbst.
$paidInput = [
'tenant_name' => 'M3 Settings Flow Updated',
'timezone' => 'Europe/Berlin',
'locale' => 'de-DE',
'currency_code' => 'EUR',
'mark_price' => '0,35',
'sheet_window_days' => '90',
'negative_warning' => '12,50',
'pdf_split_mode' => 'alphabetical',
'pdf_row_height_px' => '18',
'payment_reminder_interval_days' => '14',
];
billing_update($pdo, $tenantId, ['plan_code' => 'basic']);
$paidOff = saas_update_tenant_settings($pdo, $tenantId, $paidInput);
settings_check_assert(
'watermark can be switched off on a paid plan',
$paidOff['ok'] === true && (int)$paidOff['settings']['watermark_enabled'] === 0,
$failures,
$passes
);
$paidOn = saas_update_tenant_settings($pdo, $tenantId, $paidInput + ['watermark_enabled' => '1']);
settings_check_assert(
'watermark can be switched back on',
$paidOn['ok'] === true && (int)$paidOn['settings']['watermark_enabled'] === 1,
$failures,
$passes
);
} }
settings_check_cleanup($pdo, $slug, $emailNorm); settings_check_cleanup($pdo, $slug, $emailNorm);
+18 -1
View File
@@ -5,7 +5,11 @@ declare(strict_types=1);
require __DIR__ . '/dev-db.php'; require __DIR__ . '/dev-db.php';
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/'); $baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
$issuePattern = '~(?:Deprecated|Warning|Fatal error|Parse error|Notice|Uncaught (?:Error|Exception))~i'; // PHP-Meldungen enden immer auf einen Doppelpunkt ("Warning: ...", in HTML
// auch "<b>Warning</b>: ..."). Der verlangte Doppelpunkt haelt harmlose
// Treffer heraus, die dieselben Woerter enthalten - z. B. das Feld
// "negative_warning" in den Mandant-Einstellungen.
$issuePattern = '~(?:Deprecated|Warning|Fatal error|Parse error|Notice|Uncaught (?:Error|Exception))(?:</b>)?\s*:~i';
// Der Smoke-Test legt sich seinen eigenen Mandanten mit bekannten Zahlen an und // Der Smoke-Test legt sich seinen eigenen Mandanten mit bekannten Zahlen an und
// meldet sich per HTTP an, statt sich auf vorhandene Daten oder eine // meldet sich per HTTP an, statt sich auf vorhandene Daten oder eine
@@ -171,6 +175,19 @@ $checks = [
'expect_redirect' => 'login.php', 'expect_redirect' => 'login.php',
'contains' => [], 'contains' => [],
], ],
[
// Der Smoke-Mandant ist im kostenlosen Tarif: der Schalter fuer den
// Hinweis auf die Kaffeeliste muss erscheinen, aber festgestellt sein.
'label' => 'Mandant-Einstellungen',
'path' => 'mandant-einstellungen.php',
'auth' => true,
'contains' => [
'Hinweis auf die Kaffeeliste',
'Logo der Kaffeeliste unten rechts anzeigen',
'Im kostenlosen Tarif bleibt dieser Hinweis eingeblendet',
'disabled',
],
],
[ [
'label' => 'Namensanpassung', 'label' => 'Namensanpassung',
'path' => 'namenanpassen.php', 'path' => 'namenanpassen.php',