diff --git a/app/billing.php b/app/billing.php
new file mode 100644
index 0000000..d97b731
--- /dev/null
+++ b/app/billing.php
@@ -0,0 +1,98 @@
+
+ */
+function billing_plans(): array
+{
+ return [
+ 'free' => ['label' => 'Kostenlos', 'max_participants' => 10, 'price_cents' => 0],
+ 'basic' => ['label' => 'Basic', 'max_participants' => 25, 'price_cents' => 399],
+ 'plus' => ['label' => 'Plus', 'max_participants' => 50, 'price_cents' => 799],
+ 'pro' => ['label' => 'Pro', 'max_participants' => 150, 'price_cents' => 1299],
+ 'enterprise' => ['label' => 'Enterprise (auf Anfrage)', 'max_participants' => null, 'price_cents' => 0],
+ ];
+}
+
+/**
+ * Returns the cheapest plan whose participant limit still covers
+ * $activeParticipantCount. Used both to show customers which plan they
+ * need and, once Stripe is wired up, to detect required upgrades.
+ */
+function billing_required_plan_code(int $activeParticipantCount): string
+{
+ foreach (billing_plans() as $code => $plan) {
+ if ($plan['max_participants'] === null || $activeParticipantCount <= $plan['max_participants']) {
+ return $code;
+ }
+ }
+
+ return 'enterprise';
+}
+
+function billing_count_active_participants(PDO $pdo, int $tenantId): int
+{
+ $stmt = $pdo->prepare('SELECT COUNT(*) FROM participants WHERE tenant_id = ? AND active = 1');
+ $stmt->execute([$tenantId]);
+
+ return (int)$stmt->fetchColumn();
+}
+
+/**
+ * @return array{tenant_id: int, plan_code: string, subscription_status: string, stripe_customer_id: ?string, stripe_subscription_id: ?string, current_period_end: ?string}
+ */
+function billing_fetch_or_init(PDO $pdo, int $tenantId): array
+{
+ $stmt = $pdo->prepare('SELECT * FROM tenant_billing WHERE tenant_id = ?');
+ $stmt->execute([$tenantId]);
+ $row = $stmt->fetch();
+
+ if ($row === false) {
+ $pdo->prepare("INSERT INTO tenant_billing (tenant_id, plan_code, subscription_status) VALUES (?, 'free', 'active')")
+ ->execute([$tenantId]);
+
+ return [
+ 'tenant_id' => $tenantId,
+ 'plan_code' => 'free',
+ 'subscription_status' => 'active',
+ 'stripe_customer_id' => null,
+ 'stripe_subscription_id' => null,
+ 'current_period_end' => null,
+ ];
+ }
+
+ return $row;
+}
+
+/**
+ * Compares the tenant's currently booked plan against what their active
+ * participant count actually requires. Purely informational until the
+ * Stripe integration lands — nothing here blocks usage or auto-upgrades.
+ *
+ * @return array{current_plan: string, required_plan: string, active_participants: int, needs_upgrade: bool}
+ */
+function billing_check_tenant(PDO $pdo, int $tenantId): array
+{
+ $billing = billing_fetch_or_init($pdo, $tenantId);
+ $activeCount = billing_count_active_participants($pdo, $tenantId);
+ $requiredPlan = billing_required_plan_code($activeCount);
+
+ $plans = array_keys(billing_plans());
+ $currentRank = array_search($billing['plan_code'], $plans, true);
+ $requiredRank = array_search($requiredPlan, $plans, true);
+
+ return [
+ 'current_plan' => $billing['plan_code'],
+ 'required_plan' => $requiredPlan,
+ 'active_participants' => $activeCount,
+ 'needs_upgrade' => $requiredRank !== false && $currentRank !== false && $requiredRank > $currentRank,
+ ];
+}
diff --git a/app/platform-admin.php b/app/platform-admin.php
index 47287cc..ca103bc 100644
--- a/app/platform-admin.php
+++ b/app/platform-admin.php
@@ -55,6 +55,7 @@ function app_backoffice_fetch_tenants(PDO $pdo): array
$stmt = $pdo->query(
"SELECT
t.id, t.slug, t.name, t.status, t.created_at,
+ COALESCE(tb.plan_code, 'free') AS plan_code,
COUNT(p.id) AS participant_count,
COALESCE(SUM(p.active), 0) AS active_participant_count,
COALESCE((
@@ -64,7 +65,8 @@ function app_backoffice_fetch_tenants(PDO $pdo): array
), 0) AS balance_cents
FROM tenants t
LEFT JOIN participants p ON p.tenant_id = t.id
- GROUP BY t.id, t.slug, t.name, t.status, t.created_at
+ LEFT JOIN tenant_billing tb ON tb.tenant_id = t.id
+ GROUP BY t.id, t.slug, t.name, t.status, t.created_at, tb.plan_code
ORDER BY t.created_at DESC"
);
diff --git a/app/saas-auth.php b/app/saas-auth.php
index 048edc0..7a2a4d1 100644
--- a/app/saas-auth.php
+++ b/app/saas-auth.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/database.php';
require_once __DIR__ . '/faq.php';
+require_once __DIR__ . '/billing.php';
function saas_email_norm(string $email): string
{
@@ -1061,6 +1062,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
$stmt->execute([$tenantId, 20, 1, 0, '']);
faq_seed_default_entries($pdo, $tenantId);
+ billing_fetch_or_init($pdo, $tenantId);
if ($existingUser === false) {
$stmt = $pdo->prepare(
diff --git a/backoffice.php b/backoffice.php
index 2dc367b..03c6602 100644
--- a/backoffice.php
+++ b/backoffice.php
@@ -2,11 +2,13 @@
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/platform-admin.php';
+require_once __DIR__ . '/app/billing.php';
$pdo = app_db_pdo();
$user = app_require_platform_admin($pdo);
$tenants = app_backoffice_fetch_tenants($pdo);
+$billingPlans = billing_plans();
include 'header.php';
include 'headerline.php';
@@ -24,17 +26,24 @@ include 'nav.php';
Kunde |
Kürzel |
Status |
+ Tarif |
Erstellt |
Teilnehmer (aktiv/gesamt) |
Saldensumme |
|
+
|
|
|
- |
+
+
+
+ benötigt:
+
+ |
/ |
€ |
Ansehen |
diff --git a/database/migrations/0014_saas_tenant_billing.sql b/database/migrations/0014_saas_tenant_billing.sql
new file mode 100644
index 0000000..e3b6a85
--- /dev/null
+++ b/database/migrations/0014_saas_tenant_billing.sql
@@ -0,0 +1,21 @@
+CREATE TABLE IF NOT EXISTS tenant_billing (
+ tenant_id INT PRIMARY KEY,
+ plan_code VARCHAR(30) NOT NULL DEFAULT 'free',
+ subscription_status VARCHAR(30) NOT NULL DEFAULT 'active',
+ stripe_customer_id VARCHAR(255) NULL,
+ stripe_subscription_id VARCHAR(255) NULL,
+ current_period_end DATETIME NULL,
+ dolibarr_thirdparty_id VARCHAR(50) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_tenant_billing_plan (plan_code),
+ CONSTRAINT fk_tenant_billing_tenant
+ FOREIGN KEY (tenant_id) REFERENCES tenants(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Alle bestehenden Mandanten starten auf dem freien Tarif; die tatsaechlich
+-- erforderliche Stufe wird zur Laufzeit anhand der aktiven Teilnehmerzahl
+-- berechnet (siehe app/billing.php), nicht fest hier eingetragen.
+INSERT INTO tenant_billing (tenant_id, plan_code, subscription_status)
+SELECT id, 'free', 'active' FROM tenants;
diff --git a/docs/billing.md b/docs/billing.md
new file mode 100644
index 0000000..69cabce
--- /dev/null
+++ b/docs/billing.md
@@ -0,0 +1,96 @@
+# Preis- und Abrechnungslogik
+
+Stand: 2026-07-16
+
+Geplante Architektur (mit dem Kunden abgestimmt): **Stripe Billing für den
+Zahlungseinzug, bestehendes Dolibarr für Rechnungsstellung/Buchhaltung.**
+Kein eigenes Rechnungssystem, kein zusätzliches ERP nur für Kaffeeliste –
+siehe Begründung unten.
+
+## Warum diese Aufteilung
+
+Drei getrennte Probleme:
+
+1. **Rechnung erzeugen** – löst Dolibarr (bereits im Einsatz für ein
+ anderes Business des Kunden), fortlaufend nummeriert, GoBD-konform.
+2. **Geld tatsächlich einziehen** – der eigentlich schwierige Teil
+ (wiederkehrende Belastung, Fehlschlag-Wiederholung, Zahlungsmittel-
+ Verwaltung). Weder ein selbstgebautes System noch Dolibarr allein lösen
+ das; ein Zahlungsanbieter mit Abo-Logik schon.
+3. **Tarif-/Nutzungsstand verwalten** – das übernimmt Kaffeeliste selbst,
+ da die Teilnehmerzahl je Mandant ohnehin schon vorhanden ist.
+
+Empfehlung: **Stripe Billing** statt Mollie, weil eine echte
+Abo-Logik mit automatischem Stufenwechsel gebraucht wird (Stripes
+Billing-Produkt ist dafür ausgereifter als Mollies Subscriptions-API);
+Mollie ist bei SEPA-Lastschrift günstiger, aber weniger auf SaaS-Abos
+zugeschnitten.
+
+## Phase 1: Datenmodell und Tariflogik (umgesetzt, keine externen Zugänge nötig)
+
+Umgesetzte Dateien:
+
+```text
+database/migrations/0014_saas_tenant_billing.sql
+app/billing.php
+app/saas-auth.php (Init bei Registrierung)
+mandant-einstellungen.php (Anzeige "Abo & Tarif")
+app/platform-admin.php, backoffice.php (Tarif-Spalte im Back-Office)
+```
+
+- Neue Tabelle `tenant_billing`: `plan_code`, `subscription_status`,
+ `stripe_customer_id`, `stripe_subscription_id`, `current_period_end`,
+ `dolibarr_thirdparty_id` (letztere beide vorbereitet für Phase 2/3,
+ noch ungenutzt).
+- `billing_plans()` ist die einzige Quelle für die Tarifstufen
+ (`free`/`basic`/`plus`/`pro`/`enterprise`), synchron mit den Preisen auf
+ `preise.php`.
+- `billing_required_plan_code()` errechnet anhand der aktiven
+ Teilnehmerzahl die günstigste ausreichende Stufe.
+- `billing_check_tenant()` vergleicht gebuchten mit benötigtem Tarif;
+ rein informativ – **kein Enforcement**, kein Sperren bei Überschreitung.
+ Solange Stripe nicht angebunden ist, wird bei Bedarf manuell
+ umgestellt (Hinweistext verweist auf Kontakt per E-Mail).
+- Neue Mandanten starten automatisch mit `plan_code = 'free'`.
+- Owner/Admin sehen ihren aktuellen Tarif und die aktive Teilnehmerzahl
+ unter „Abo & Tarif" auf `mandant-einstellungen.php`; bei Bedarf einer
+ höheren Stufe erscheint ein Hinweis.
+- Das Back-Office (`backoffice.php`) zeigt zusätzlich zur Mandantenliste
+ den gebuchten Tarif und, falls abweichend, den tatsächlich benötigten
+ Tarif – der Betreiber sieht auf einen Blick, wer manuell hochgestuft
+ werden müsste.
+
+Live getestet: Tarifgrenzen-Berechnung (10/25/50/150) mit einem Mandanten
+knapp unter und einem mit 12 Teilnehmern (über dem Freikontingent, korrekt
+als „basic" erkannt) geprüft; UI-Anzeige in Mandant-Einstellungen und
+Back-Office live verifiziert. Alle bestehenden M8-Isolationstests und der
+Rollen-Matrix-Test bleiben unverändert grün.
+
+## Phase 2: Stripe-Anbindung (noch offen)
+
+Wartet auf Stripe-Test-API-Keys vom Kunden. Geplanter Umfang:
+
+- Stripe Checkout zum Abschließen/Wechseln eines bezahlten Tarifs.
+- Webhook-Endpunkt für `checkout.session.completed`,
+ `customer.subscription.updated`/`.deleted`, `invoice.paid`/`.payment_failed`,
+ der `tenant_billing` aktuell hält.
+- Stripe Customer Portal für Zahlungsmittel-Verwaltung/Kündigung durch
+ den Kunden selbst.
+- Automatischer Stufenwechsel: Sobald `billing_check_tenant()` einen
+ höheren Bedarf erkennt, Wechsel der Stripe-Subscription auslösen
+ (mit Proration) statt nur anzuzeigen.
+
+## Phase 3: Dolibarr-Anbindung (noch offen)
+
+Wartet auf Zugang zu einer Dolibarr-Sandbox/Testinstanz (bewusst nicht
+direkt gegen die produktive Instanz des Kunden, um deren bestehende
+Buchhaltungsdaten nicht zu gefährden). Geplanter Umfang:
+
+- Bei jedem erfolgreichen Stripe-Zahlungsereignis (`invoice.paid`) über
+ die Dolibarr-REST-API eine Rechnung im bestehenden Dolibarr des Kunden
+ anlegen (`dolibarr_thirdparty_id` verknüpft den Mandanten mit dem
+ Dolibarr-Kunden).
+- Stripe bleibt alleiniger Zahlungsweg; Dolibarr wird nur als Buchhaltungs-
+ Spiegel befüllt, nicht selbst zum Auslösen von Zahlungen verwendet.
+- Erst nach erfolgreichem Test gegen die Sandbox gegen die produktive
+ Dolibarr-Instanz freigeben.
diff --git a/mandant-einstellungen.php b/mandant-einstellungen.php
index 66cf671..f770d7a 100644
--- a/mandant-einstellungen.php
+++ b/mandant-einstellungen.php
@@ -2,6 +2,7 @@
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/audit.php';
+require_once __DIR__ . '/app/billing.php';
$pdo = app_db_pdo();
$user = saas_require_login();
@@ -38,6 +39,8 @@ if ($canManageSettings) {
}
$auditLog = app_fetch_audit_log($pdo, (int)$user['tenant_id'], 50);
+ $billingCheck = billing_check_tenant($pdo, (int)$user['tenant_id']);
+ $billingPlans = billing_plans();
}
include 'header.php';
@@ -117,6 +120,22 @@ include 'nav.php';
+ Abo & Tarif
+
+ Aktueller Tarif:
+ Aktive Teilnehmer:
+
+
+
+
Mit aktiven Teilnehmern benötigt ihr den
+ Tarif „".
+ Die automatische Abrechnung ist noch in Vorbereitung – bitte meldet euch bei uns,
+ wir stellen den passenden Tarif manuell um. Details zu den Stufen stehen unter Preise.
+
+
+ Euer aktueller Tarif deckt eure Teilnehmerzahl ab.
+
+
Protokoll
Die letzten Admin-Aktionen für diesen Mandanten.