Statt automatisch hochzustufen, blockiert das Anlegen/Reaktivieren weiterer aktiver Mitglieder, sobald das Limit des gebuchten Tarifs erreicht ist (Neuanlage, Bearbeiten mit Statuswechsel, Aktivieren- Button). mitarbeiterverwalten.php zeigt die aktuelle Auslastung an. Neuer Regressionstest scripts/check-billing-capacity.php deckt alle drei Enforcement-Stellen ab.
13 KiB
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:
- Rechnung erzeugen – löst Dolibarr (bereits im Einsatz für ein anderes Business des Kunden), fortlaufend nummeriert, GoBD-konform.
- 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.
- 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:
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 aufpreise.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:
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 stabilenlookup_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 (Modussubscription) für den vom Kunden gewählten Tarif und leitet dorthin weiter.tenant_id/plan_codewerden 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 dieStripe-Signaturenach dem von Stripe dokumentierten HMAC-SHA256-Schema (inklusive Zeitstempel-Toleranz gegen Replay), verarbeitetcheckout.session.completed,customer.subscription.updated/.deleted,invoice.paid/.payment_failedund hälttenant_billingaktuell. Bewusst keinapp_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:
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.$additionalnoch 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.phpzeigt 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.completedsetzttenant_billingkorrekt (Tarif, Status, Stripe-IDs) und wird im Mandanten-Audit-Log protokolliert;customer.subscription.deletedsetzt den Mandanten korrekt auffree/canceledzurück. - Signaturprüfung: eine Anfrage mit falscher Signatur wird korrekt mit
400abgelehnt. - Customer Portal: mit einem echten, per API angelegten Stripe-Test-Kunden
liefert
abo-portal.phpeinen echten 302-Redirect zubilling.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 alsSTRIPE_WEBHOOK_SECREThinterlegen – 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:
app/dolibarr.php
stripe-webhook.php (invoice.paid ruft dolibarr_sync_invoice_paid())
app/dolibarr.php: minimaler REST-Client nach demselben Muster wieapp/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 intenant_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 = 0durchgängig, da Kleinunternehmer nach § 19 UStG.stripe-webhook.php:invoice.paidlöst jetzt zusätzlich zum Audit-Log-Eintragdolibarr_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 alsbilling.dolibarr_invoice_failedim 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,/invoicesgegen 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,DELETEliefert403). - 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_createdbzw.billing.dolibarr_invoice_failed), um denvalidate()-Aufruf einmal scharf zu bestätigen. - Zahlungserfassung in Dolibarr (Rechnung als bezahlt markieren) als möglicher Ausbau, sobald Bankkonto-/Zahlungsart-IDs bekannt sind.