Files
kaffeekasse-saas/docs/billing.md
T
clemens dd2277c803 Selbstbedienungs-Tarifverwaltung: Admin kann Abo im Backend waehlen und bestellen
- mandant-einstellungen.php: vollstaendige Tarif-Vergleichstabelle statt nur
  erzwungenem Upgrade-Hinweis. Jeder Tarif zeigt Limit, Preis und einen
  Aktions-Button (Buchen/Kuendigen/Anfragen); aktueller Tarif markiert,
  zu kleine Tarife fuer die aktuelle Teilnehmerzahl deaktiviert.
- abo-upgrade.php: unterscheidet jetzt Upgrade, Downgrade zwischen bezahlten
  Stufen (Preis wird in-place gewechselt statt neuer Checkout-Session,
  Stripe prorated automatisch) und Wechsel auf 'free' (echte Kuendigung der
  bestehenden Subscription), statt nur eine Checkout-Session zu erzeugen.
- app/stripe.php: neue Helper stripe_get_subscription(),
  stripe_update_subscription_price(), stripe_cancel_subscription().
- app/billing.php: billing_plan_selectable_for() prueft serverseitig, ob
  ein Zieltarif die aktuelle Teilnehmerzahl noch abdeckt (Downgrade-Schutz).
- docs/billing.md: Phase 4 dokumentiert.

Getestet: php -l fuer alle geaenderten Dateien und das gesamte Repo (keine
Syntaxfehler), reine Funktionslogik-Tests ohne DB (billing_plans(),
billing_required_plan_code(), stripe_flatten_params()) gruen. Kein Live-Test
gegen echte Stripe-Testobjekte moeglich (keine PHP/DB-Laufzeitumgebung
verfuegbar) - vor Go-Live im Stripe-Testmodus nachholen.
2026-07-20 20:19:05 +00:00

330 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (umgesetzt)
Umgesetzte Dateien:
```text
app/stripe.php
app/billing.php (Stripe-Preis-Auflösung ergänzt)
abo-upgrade.php
abo-portal.php
stripe-webhook.php
mandant-einstellungen.php (Upgrade-/Portal-Buttons)
```
- `app/stripe.php`: minimaler REST-Client für die Stripe-API auf Basis von
PHP-Streams (`file_get_contents` + `stream_context_create`), **kein**
vendortes SDK und **kein** composer.json nötig diese PHP-Installation
hat ohnehin keine curl-Extension, Streams sind portabler.
- Für jeden bezahlten Tarif (`basic`/`plus`/`pro`) wurde per API ein
Stripe-Produkt mit monatlichem Preis angelegt, referenziert über einen
stabilen `lookup_key` (`kaffeeliste_basic`/`_plus`/`_pro`) statt einer
hartcodierten Price-ID `stripe_find_or_create_price()` ist idempotent,
ein erneuter Aufruf legt nichts doppelt an.
- `abo-upgrade.php`: erstellt eine Stripe-Checkout-Session (Modus
`subscription`) für den vom Kunden gewählten Tarif und leitet dorthin
weiter. `tenant_id`/`plan_code` werden sowohl auf der Checkout-Session
als auch auf der Subscription selbst als Metadaten gesetzt, damit spätere
Subscription-Events (die kein Checkout-Session-Objekt mehr enthalten)
trotzdem einem Mandanten zugeordnet werden können.
- `abo-portal.php`: öffnet das Stripe Customer Portal für den bestehenden
Stripe-Kunden (Zahlungsmittel ändern, Abo kündigen) Self-Service ohne
eigene UI dafür.
- `stripe-webhook.php`: verifiziert die `Stripe-Signature` nach dem von
Stripe dokumentierten HMAC-SHA256-Schema (inklusive Zeitstempel-Toleranz
gegen Replay), verarbeitet `checkout.session.completed`,
`customer.subscription.updated`/`.deleted`,
`invoice.paid`/`.payment_failed` und hält `tenant_billing` aktuell.
Bewusst kein `app_require_csrf()`/`saas_require_login()` (Stripe ruft
unauthentifiziert von außen auf), stattdessen ausschließlich die
Signaturprüfung als Echtheitsnachweis.
- `mandant-einstellungen.php`: zeigt bei Bedarf einen echten
„Jetzt upgraden"-Button (führt zu Stripe Checkout) sowie, sobald ein
Stripe-Kunde existiert, „Zahlungsmethode verwalten / Abo kündigen"
(führt zum Customer Portal).
**Bewusst nicht umgesetzt:** automatischer Stufenwechsel bei wachsender
Teilnehmerzahl. Stattdessen gilt eine harte Obergrenze (siehe unten):
wer mehr aktive Mitglieder braucht, muss selbst upgraden.
## Kapazitätsgrenze je Tarif (umgesetzt)
Statt automatisch hochzustufen, wird die Teilnehmerzahl pro Mandant hart
auf das Limit des aktuell gebuchten Tarifs begrenzt.
Umgesetzte Dateien:
```text
app/billing.php (billing_has_capacity_for(), billing_capacity_error_message())
mitarbeiterverwalten.php (Pruefung vor Anlegen/Aktivieren/Bearbeiten)
scripts/check-billing-capacity.php (Regressionstest)
```
- `billing_has_capacity_for($pdo, $tenantId, $additional = 1)`: prüft,
ob die aktive Teilnehmerzahl zzgl. `$additional` noch innerhalb des
Limits des gebuchten Tarifs liegt (`enterprise` = unbegrenzt).
- Geprüft an allen drei Stellen, die einen Teilnehmer aktiv anlegen oder
reaktivieren können: Neuanlage mit gesetztem „Aktiv"-Haken, Bearbeiten
eines bisher inaktiven Mitglieds mit gesetztem Haken, sowie der
dedizierte „Aktivieren"-Button. Deaktivieren, reines Bearbeiten ohne
Statuswechsel und CSV-Import (bucht nur Zahlungen auf bestehende
Mitglieder, legt keine neuen an) sind nicht betroffen.
- Bei Überschreitung erscheint eine Fehlermeldung mit Tarifname, Limit
und Verweis auf Mandant-Einstellungen zum Upgraden; die Aktion wird
nicht ausgeführt.
- `mitarbeiterverwalten.php` zeigt zusätzlich durchgehend „Aktive
Mitglieder: X von maximal Y" an, damit das Limit vor dem Anlegen
sichtbar ist.
- Live getestet (`scripts/check-billing-capacity.php`, 10 Assertionen):
10. aktives Mitglied bei Cap 10 wird angelegt, 11. wird abgelehnt und
landet nicht in der DB, inaktives Anlegen bei vollem Cap funktioniert
weiterhin, Aktivieren eines inaktiven Mitglieds bei vollem Cap wird
abgelehnt, nach Deaktivieren eines anderen Mitglieds klappt es, und
die Reaktivierung über das Bearbeiten-Formular wird ebenso geprüft.
Alle bestehenden Tenants lagen zum Zeitpunkt der Umsetzung deutlich
unter ihrem Free-Limit (max. 8 von 10 aktiven Teilnehmern), daher keine
Auswirkung auf laufenden Betrieb.
### Live getestet (Testmodus, kein echtes Geld)
- Checkout-Session-Erstellung direkt über die API sowie über den vollen
Seiten-Flow (Login → Mandant-Einstellungen zeigt Upgrade-Button bei 13
Teilnehmern → Klick → echter 302-Redirect zu einer
`checkout.stripe.com`-URL).
- Webhook-Verarbeitung durch selbst erzeugte, korrekt signierte
Test-Ereignisse (da diese Dev-Umgebung keine öffentlich erreichbare URL
für echte Stripe-Zustellung hat): `checkout.session.completed` setzt
`tenant_billing` korrekt (Tarif, Status, Stripe-IDs) und wird im
Mandanten-Audit-Log protokolliert; `customer.subscription.deleted`
setzt den Mandanten korrekt auf `free`/`canceled` zurück.
- Signaturprüfung: eine Anfrage mit falscher Signatur wird korrekt mit
`400` abgelehnt.
- Customer Portal: mit einem echten, per API angelegten Stripe-Test-Kunden
liefert `abo-portal.php` einen echten 302-Redirect zu
`billing.stripe.com`.
- Alle bestehenden Regressionstests (Golden Master, M8-Isolation,
M8-Rollenmatrix, HTTP-Smoke mit 36 Seiten) weiterhin grün.
- Alle Testdaten (Stripe-Testobjekte kosten nichts und wurden im
Stripe-Testmodus belassen; lokale DB-Testdaten wurden entfernt)
aufgeräumt.
### Für den Produktivbetrieb noch zu tun
- Echten Webhook-Endpunkt im Stripe-Dashboard (oder per API) auf die
produktive Domain (`https://app.kaffeeliste.de/stripe-webhook.php`)
eintragen und das dortige Signing-Secret als `STRIPE_WEBHOOK_SECRET`
hinterlegen das aktuelle Secret ist nur ein lokal erzeugter Platzhalter
für die Simulation in dieser Dev-Umgebung.
- Von Test- auf Live-API-Keys wechseln, sobald ein echter Stripe-Account
mit Bankverbindung eingerichtet ist.
## Phase 3: Dolibarr-Anbindung (umgesetzt)
**Ausgangslage:** Der Kunde betreibt nur eine einzige Dolibarr-Instanz,
und das ist die produktive für sein anderes Business keine separate
Sandbox verfügbar (Dolibarr 23.0.3, `https://verwaltung.ctb-it.de`).
Der ursprüngliche Plan „erst gegen Sandbox testen" wurde daher ersetzt
durch sicheres Testen **innerhalb** der einen produktiven Instanz.
Umgesetzte Dateien:
```text
app/dolibarr.php
stripe-webhook.php (invoice.paid ruft dolibarr_sync_invoice_paid())
```
- `app/dolibarr.php`: minimaler REST-Client nach demselben Muster wie
`app/stripe.php` (PHP-Streams, kein SDK). `dolibarr_find_or_create_thirdparty()`
legt bei Erstkontakt einen Dolibarr-Kunden für den Mandanten an
(`Kaffeeliste - <Mandantenname>`, E-Mail des Owners) und cached die
Dolibarr-Kunden-ID in `tenant_billing.dolibarr_thirdparty_id`.
`dolibarr_create_and_validate_invoice()` legt eine Rechnung mit einer
Freitext-Position an (kein eigenes Dolibarr-Produkt pro Tarif nötig),
fügt die Position hinzu und validiert die Rechnung sofort (vergibt die
permanente, fortlaufende Nummer). `tva_tx = 0` durchgängig, da
Kleinunternehmer nach § 19 UStG.
- `stripe-webhook.php`: `invoice.paid` löst jetzt zusätzlich zum
Audit-Log-Eintrag `dolibarr_sync_invoice_paid()` aus. Ein Fehlschlag
dort (Netzwerk, Dolibarr down, ungültige Daten) wird **nicht** an
Stripe als Fehler zurückgemeldet Stripe hat das Geld bereits
erfolgreich eingezogen, das ist unabhängig vom Dolibarr-Spiegel. Der
Fehler landet stattdessen als `billing.dolibarr_invoice_failed` im
Mandanten-Audit-Log (sichtbar sowohl unter „Protokoll" in
Mandant-Einstellungen als auch im Back-Office) zur manuellen
Nachbearbeitung.
- Rechnungen werden **automatisch validiert** (nicht als Entwurf für
manuelle Prüfung liegen gelassen) bewusste Entscheidung des Kunden,
um den Prozess vollautomatisch zu halten.
- Das automatische **Erfassen der Zahlung** in Dolibarr (Rechnung als
bezahlt markieren) ist bewusst **nicht** umgesetzt erfordert
Kenntnis der Bankkonto-/Zahlungsart-IDs der Dolibarr-Instanz. Rechnungen
erscheinen offen und werden vom Kunden manuell abgeglichen.
- Stripe bleibt alleiniger Zahlungsweg; Dolibarr wird nur als Buchhaltungs-
Spiegel befüllt, nicht selbst zum Auslösen von Zahlungen verwendet.
### Live getestet
- **Verbindung/Lesezugriff**: `GET /status`, `/thirdparties`,
`/invoices` gegen die echte Instanz erfolgreich geprüft (Version,
Erreichbarkeit, Rechte des API-Keys).
- **Schreibzugriff (mit expliziter Freigabe des Kunden)**: ein eindeutig
benannter Test-Kunde („TEST Kaffeeliste Integration") sowie eine
Entwurfs-Rechnung mit Position wurden über die API angelegt und die
Struktur verifiziert (`status: 0` = Entwurf, `ref: (PROVx)` = noch
keine permanente Nummer, korrekter Betrag, korrekte Verknüpfung).
**Nicht validiert** der Kunde hat die beiden Testdatensätze danach
manuell in der Dolibarr-Oberfläche gelöscht (der beschränkte API-Key
hat kein Lösch-Recht, `DELETE` liefert `403`).
- **Bewusst nicht live getestet**: der eigentliche `validate()`-Aufruf,
der die permanente Rechnungsnummer vergibt das hätte einen
unlöschbaren Testeintrag in der produktiven Buchhaltung hinterlassen.
Auf Wunsch des Kunden ist die erste echte Stripe-Zahlung nach dem
Go-Live der erste echte Test dieses Schritts. Das Risiko ist durch die
fehlertolerante Webhook-Verarbeitung begrenzt (siehe oben).
- Alle bestehenden Regressionstests (Golden Master, M8-Isolation,
M8-Rollenmatrix, HTTP-Smoke mit 36 Seiten) weiterhin grün.
### Für den Produktivbetrieb noch zu tun
- Nach dem ersten echten `invoice.paid`-Ereignis in Produktion das
Mandanten-Audit-Log prüfen (`billing.dolibarr_invoice_created` bzw.
`billing.dolibarr_invoice_failed`), um den `validate()`-Aufruf einmal
scharf zu bestätigen.
- Zahlungserfassung in Dolibarr (Rechnung als bezahlt markieren) als
möglicher Ausbau, sobald Bankkonto-/Zahlungsart-IDs bekannt sind.
## Phase 4: Selbstbedienungs-Tarifverwaltung im Mandanten-Backend (umgesetzt)
**Ausgangslage:** Bislang sah der Mandant nur einen erzwungenen
Upgrade-Hinweis, sobald sein aktueller Tarif die Teilnehmerzahl nicht mehr
deckte (`billingCheck['needs_upgrade']`); er konnte keinen anderen Tarif
frei wählen, kein Downgrade vornehmen und keine echte Bestellung auslösen,
solange kein Pflicht-Upgrade anstand.
Umgesetzte Dateien:
```text
app/stripe.php (stripe_get_subscription, stripe_update_subscription_price, stripe_cancel_subscription)
app/billing.php (billing_plan_selectable_for)
abo-upgrade.php (Fallunterscheidung Upgrade/Downgrade/Kündigung)
mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-Button je Tarif)
```
- **Tarif-Vergleichstabelle** in „Abo & Tarif" zeigt jetzt alle Tarifstufen
(nicht nur den benötigten) mit Limit, Preis und einem Aktions-Button je
Zeile: aktueller Tarif ist markiert, Tarife die die aktuelle
Teilnehmerzahl nicht abdecken sind deaktiviert
(`billing_plan_selectable_for()`), Tarife ohne Stripe-Preis (`enterprise`)
verlinken auf eine manuelle Anfrage per E-Mail.
- **`abo-upgrade.php`** unterscheidet jetzt drei Fälle statt nur
„Checkout-Session erzeugen":
1. Zielwert `free` mit bestehender bezahlter Subscription → echtes
`stripe_cancel_subscription()`, lokal auf `free`/`canceled` gesetzt.
2. Zielwert ein anderer bezahlter Tarif **und** bereits eine
aktive/`trialing`/`past_due`-Subscription vorhanden → Preis wird
in-place über `stripe_update_subscription_price()` gewechselt
(Stripe-Proration übernimmt die anteilige Verrechnung), kein neuer
Checkout-Redirect nötig. Lokal sofort gespiegelt, der Webhook
(`customer.subscription.updated`) bestätigt denselben Stand redundant.
3. Kein aktives Abo bisher → wie vorher eine neue Stripe-Checkout-Session.
- **`app/stripe.php`** neu: `stripe_get_subscription()` (liest Status,
Subscription-Item-ID und aktuellen `lookup_key`),
`stripe_update_subscription_price()` (Preis am bestehenden
Subscription-Item austauschen, `cancel_at_period_end` explizit auf
`false` ein Tarifwechsel ist ein Signal, das Abo fortzuführen),
`stripe_cancel_subscription()` (sofortige Kündigung, nicht zum
Periodenende).
- **`app/billing.php`** neu: `billing_plan_selectable_for()` prüft, ob die
aktive Teilnehmerzahl das Limit eines Zieltarifs einhält Basis für das
Deaktivieren nicht passender Tarife in der UI und für die
Server-seitige Validierung in `abo-upgrade.php`.
### Bewusste Entscheidungen
- Downgrade auf einen bezahlten, aber zu kleinen Tarif wird serverseitig
abgelehnt (`billing_plan_selectable_for()`), nicht nur clientseitig
ausgegraut verhindert, dass ein Mandant sich selbst unter sein
eigenes Teilnehmerlimit bucht.
- `enterprise` bleibt bewusst kein Selbstbedienungs-Ziel (kein
Stripe-Preis vorhanden, Preis „auf Anfrage"); Auswahl verlinkt auf
manuellen Kontakt statt eine falsche Buchung zu versuchen.
- Bestätigungsdialog (JS `confirm()`) vor jedem Tarifwechsel, da ein
Wechsel bei Stripe sofort wirksam wird und anteilig verrechnet wird.
### Noch offen
- Kein automatisierter Live-Test gegen echte Stripe-Testobjekte für den
In-Place-Preiswechsel (`stripe_update_subscription_price`) die
vorhandene Dev-Umgebung hat keinen PHP-Interpreter/DB-Zugriff für einen
Live-Check; nur Syntax- und reine Funktionslogik-Tests ohne DB/Stripe-
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`).