diff --git a/app/bootstrap.php b/app/bootstrap.php
index 92b9f74..7b4a047 100644
--- a/app/bootstrap.php
+++ b/app/bootstrap.php
@@ -99,6 +99,33 @@ function app_primary_url(string $path): string
return $scheme . '://' . $host . '/' . ltrim($path, '/');
}
+/**
+ * Adresse der Marketing-Domain (z. B. https://kaffeeliste.de) - Ziel des
+ * Wasserzeichens im kostenlosen Tarif. Explizit ueber APP_MARKETING_URL
+ * setzbar; sonst wird sie aus APP_HOST abgeleitet, indem die
+ * App-Subdomain wegfaellt (app.kaffeeliste.de -> kaffeeliste.de). In der
+ * lokalen Entwicklung ohne APP_HOST bleibt der aktuelle Host uebrig, im
+ * CLI-Betrieb ein leerer String.
+ */
+function app_marketing_url(): string
+{
+ $configured = app_env('APP_MARKETING_URL');
+ if ($configured !== null) {
+ return rtrim($configured, '/');
+ }
+
+ $scheme = app_is_https() ? 'https' : 'http';
+ $host = app_primary_host();
+
+ if ($host !== null) {
+ return $scheme . '://' . preg_replace('/^app\./i', '', $host);
+ }
+
+ $requestHost = (string)($_SERVER['HTTP_HOST'] ?? '');
+
+ return $requestHost !== '' ? $scheme . '://' . $requestHost : '';
+}
+
function app_start_session(): void
{
if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
diff --git a/app/plan-watermark.php b/app/plan-watermark.php
new file mode 100644
index 0000000..f710ef6
--- /dev/null
+++ b/app/plan-watermark.php
@@ -0,0 +1,93 @@
+prepare('SELECT plan_code FROM tenant_billing WHERE tenant_id = ?');
+ $stmt->execute([$tenantId]);
+ $planCode = $stmt->fetchColumn();
+ $cache[$tenantId] = $planCode === false ? 'free' : (string)$planCode;
+ } catch (Throwable $e) {
+ // Laesst sich der Tarif nicht lesen, bleibt die Oberflaeche sauber -
+ // ein Wasserzeichen bei einem zahlenden Kunden waere der schlimmere
+ // Fehler als ein fehlendes bei einem kostenlosen.
+ $cache[$tenantId] = 'unknown';
+ }
+
+ return $cache[$tenantId];
+}
+
+/**
+ * Zeigt dieser Mandant das Wasserzeichen? Nur der kostenlose Tarif tut das.
+ */
+function plan_watermark_visible(PDO $pdo, int $tenantId): bool
+{
+ if ($tenantId <= 0) {
+ return false;
+ }
+
+ return plan_watermark_plan_code($pdo, $tenantId) === 'free';
+}
+
+/**
+ * Wortmarke der Kaffeeliste als Inline-SVG. Als Inline-Grafik statt als
+ * Bilddatei, damit das Wasserzeichen ohne zusaetzlichen Request auskommt,
+ * auf jedem Bildschirm scharf bleibt und die Farbe der Umgebung uebernimmt.
+ */
+function plan_watermark_logo_svg(): string
+{
+ return '';
+}
+
+/**
+ * Markup des Wasserzeichens. Ohne erreichbare Marketing-Domain (CLI,
+ * ungewoehnliche Hosts) bleibt es bei Logo und Namen ohne Link - ein
+ * Verweis ins Leere waere schlechter als keiner.
+ */
+function plan_watermark_html(): string
+{
+ $url = app_marketing_url();
+ $label = $url !== '' ? preg_replace('#^https?://#i', '', $url) : 'Kaffeeliste';
+ $inner = plan_watermark_logo_svg()
+ . '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '';
+
+ if ($url === '') {
+ return '
' . $inner . '
';
+ }
+
+ return '' . $inner . '';
+}
diff --git a/assets/css/app.css b/assets/css/app.css
index 3654de6..a4daac3 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -163,3 +163,55 @@ tr.tarif-aktuell > td {
tr.tarif-aktuell > td:first-child {
box-shadow: inset 5px 0 0 0 #4caf50;
}
+
+/* ---------------------------------------------------------------------------
+ * Wasserzeichen im kostenlosen Tarif (app/plan-watermark.php)
+ *
+ * Fest unten rechts, damit es unabhaengig von der Seitenlaenge sichtbar
+ * bleibt. Zurueckhaltend gehalten: die Kaffeeliste ist eine Arbeitsflaeche,
+ * der Hinweis soll erkennbar sein, aber nichts verdecken - deshalb halb
+ * transparent, erst beim Darueberfahren voll sichtbar.
+ * ------------------------------------------------------------------------ */
+.plan-watermark {
+ align-items: center;
+ background: rgba(255, 255, 255, 0.9);
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ border-radius: 999px;
+ bottom: 0.9rem;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
+ color: #4a4a4a;
+ display: inline-flex;
+ font-size: 0.7rem;
+ gap: 0.4em;
+ letter-spacing: 0.02em;
+ line-height: 1;
+ opacity: 0.65;
+ padding: 0.45em 0.8em;
+ position: fixed;
+ right: 0.9rem;
+ text-decoration: none;
+ transition: opacity 0.2s ease;
+ z-index: 900;
+}
+
+a.plan-watermark {
+ border-bottom: 0;
+}
+
+.plan-watermark:hover,
+.plan-watermark:focus {
+ color: #4a4a4a;
+ opacity: 1;
+}
+
+.plan-watermark-logo {
+ flex: 0 0 auto;
+}
+
+/* Auf schmalen Bildschirmen bleibt nur das Logo - der Schriftzug wuerde dort
+ ueber den Inhalt laufen. */
+@media screen and (max-width: 480px) {
+ .plan-watermark-text {
+ display: none;
+ }
+}
diff --git a/docs/billing.md b/docs/billing.md
index 44455f6..8773e4a 100644
--- a/docs/billing.md
+++ b/docs/billing.md
@@ -327,3 +327,37 @@ mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-But
Zugriff wurden hier durchgeführt. Vor Go-Live einmal im Stripe-Testmodus
gegen einen echten Test-Mandanten durchspielen (Upgrade, Downgrade
zwischen zwei bezahlten Stufen, Downgrade auf `free`).
+
+## Wasserzeichen im kostenlosen Tarif (umgesetzt)
+
+Stand: 2026-08-21. Mandanten im Tarif `free` (bis 10 Teilnehmer) zeigen
+unten rechts eine kleine Marke mit Logo und Adresse der Kaffeeliste.
+Zahlende Mandanten sehen die Oberfläche ohne fremdes Logo.
+
+Umgesetzte Dateien:
+
+```text
+app/plan-watermark.php (Sichtbarkeit + Markup)
+app/bootstrap.php (app_marketing_url())
+footer.php (Einbindung vor den Skripten)
+assets/css/app.css (.plan-watermark)
+scripts/http-smoke.php (Dashboard-Check)
+```
+
+- **Nur der Tarif entscheidet.** `plan_watermark_visible()` blendet die
+ Marke ein, wenn `tenant_billing.plan_code` `free` ist – fehlt der
+ Datensatz, gilt wie beim Anlegen `free`. Lässt sich der Tarif nicht
+ lesen, bleibt die Oberfläche sauber: ein Wasserzeichen bei einem
+ zahlenden Kunden wäre der schlimmere Fehler.
+- **Keine Schreibzugriffe im Seitenaufbau.** Bewusst nicht
+ `billing_fetch_or_init()`, das eine fehlende Zeile anlegen würde; die
+ Prüfung ist eine reine Leseabfrage und wird pro Request zwischengespeichert.
+- **Logo als Inline-SVG.** Spart einen Request, bleibt auf jedem Bildschirm
+ scharf und übernimmt die Farbe der Umgebung. Ein eigenes Bild lässt sich
+ in `plan_watermark_logo_svg()` durch ein `
` ersetzen.
+- **Ziel der Verlinkung** ist die Marketing-Domain: `APP_MARKETING_URL`,
+ ersatzweise `APP_HOST` ohne die App-Subdomain
+ (`app.kaffeeliste.de` → `kaffeeliste.de`).
+- Getrennt von `app/branding.php`: dort geht es um das Logo *des Mandanten*
+ (Kopfzeile), hier um das *des Anbieters*. Beides kann gleichzeitig
+ sichtbar sein.
diff --git a/env.local.example.php b/env.local.example.php
index 64874fb..ba7ae1a 100644
--- a/env.local.example.php
+++ b/env.local.example.php
@@ -24,6 +24,11 @@ putenv('APP_TIMEZONE=Europe/Berlin');
// stattdessen die Landingpage. Siehe index.php.
putenv('APP_HOST=app.kaffeeliste.de');
+// Ziel des Wasserzeichens im kostenlosen Tarif (unten rechts in der App).
+// Ohne Angabe wird die Marketing-Domain aus APP_HOST abgeleitet, indem die
+// App-Subdomain wegfaellt - hier also https://kaffeeliste.de.
+putenv('APP_MARKETING_URL=https://kaffeeliste.de');
+
putenv('APP_DB_DRIVER=mysql');
putenv('DB_HOST=');
putenv('DB_PORT=3306');
diff --git a/footer.php b/footer.php
index 879348b..01ef441 100644
--- a/footer.php
+++ b/footer.php
@@ -142,6 +142,17 @@ if ($saasNavUser !== null) {
+ 0) {
+ require_once __DIR__ . '/app/plan-watermark.php';
+ if (plan_watermark_visible(app_db_pdo(), $saasNavTenantId)) {
+ echo plan_watermark_html();
+ }
+}
+?>
+
diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php
index ea4ec1b..afa8d00 100644
--- a/scripts/http-smoke.php
+++ b/scripts/http-smoke.php
@@ -52,7 +52,9 @@ $checks = [
'label' => 'Dashboard',
'path' => 'index.php',
'auth' => true,
- 'contains' => ['Aktueller Stand', 'Jahresübersicht', 'Letzte Einzahlungen'],
+ // Der Smoke-Mandant laeuft im kostenlosen Tarif, also muss das
+ // Wasserzeichen unten rechts mitkommen.
+ 'contains' => ['Aktueller Stand', 'Jahresübersicht', 'Letzte Einzahlungen', 'class="plan-watermark"'],
],
[
'label' => 'Striche erfassen',