
LaizyNote ist im September 2026 von Firebase auf Supabase umgezogen. Dieser Artikel beschreibt die Abrechnung, wie sie heute läuft: Postgres in Frankfurt, Zugriffsregeln direkt in der Datenbank und Edge Functions für die Stripe-Anbindung. Wie der Umzug selbst ablief, steht im Artikel Von Firebase zu Supabase.
Eine SaaS-Abrechnung endet nicht beim erfolgreichen Stripe-Checkout. Erst danach beginnt die eigentliche Produktlogik: Welchen Tarif besitzt der Nutzer? Welche Funktionen darf er verwenden? Was passiert bei einer Kündigung, einem fehlgeschlagenen Zahlungsversuch oder einem verspäteten Webhook?
Für LaizyNote habe ich diese Verbindung selbst entwickelt. Stripe übernimmt Zahlung, Rechnungen und Zahlungsversuche. Supabase Auth identifiziert den Nutzer, Edge Functions verarbeiten die Zahlungsereignisse, und Postgres hält den für die Anwendung wirksamen Abostatus – zusammen mit den Zugriffsregeln, die direkt in der Datenbank greifen. Der folgende Deep Dive ergänzt meinen Rückblick zur Entwicklung von LaizyNote um genau diese technische Ebene.
Die Zahlung ist nicht die Berechtigung
LaizyNote hat zwei Tarife: Free und Plus. Dazu kommen monatliche und jährliche Abrechnung, Gutscheine, Empfehlungsboni, zeitlich begrenzte Zugänge und zusätzliche Daisy-Credits. Hinter einer scheinbar einfachen Tarifauswahl steckt damit eine Zustandsmaschine.
Die Anwendung darf nie allein einem Button oder einem URL-Parameter glauben. Ein Nutzer könnte den Checkout abbrechen, zweimal öffnen oder bereits ein aktives Abo besitzen. Ein Webhook kann verspätet eintreffen. Eine Zahlung kann nach der ersten Aktivierung fehlschlagen. Der gespeicherte Tarif muss deshalb aus verifizierten Stripe-Daten entstehen.
- Der angemeldete Nutzer wählt Tarif und Abrechnungszeitraum.
- Eine Edge Function validiert die Auswahl und erstellt den Stripe-Checkout.
- Stripe bestätigt Zahlung und Abonnement über signierte Webhooks.
- Die Webhook-Logik schreibt Tarif, Status und Laufzeit in die Tabelle
subscriptions. - Frontend und Zugriffsregeln der Datenbank lesen daraus den wirksamen Tarif.
Vom Checkout zum Tarif – ohne Doppelabos
Die Checkout-Session wird serverseitig in einer Edge Function erzeugt. Sie prüft die Anmeldung und akzeptiert nur bekannte Preis-IDs. Hat der Nutzer bereits ein aktives Abo, wird kein zweites eröffnet; Änderungen laufen stattdessen über das Stripe-Kundenportal. Ist noch eine offene Checkout-Session vorhanden, wird sie wiederverwendet statt neu angelegt.
subscriptions; erst app.effective_tier() entscheidet, welcher Tarif gilt. Die gestrichelten Pfade sind das Sicherheitsnetz für verpasste oder widersprüchliche Zustände.Gleichzeitige Checkout-Versuche fängt eine kurzlebige Sperre pro Nutzer ab, die als Zeile in der Datenbank liegt. Idempotency Keys sorgen zusätzlich dafür, dass ein wiederholter Request nicht mehrere Stripe-Objekte erzeugt. Rabatte wendet ausschließlich der Server an; Gutscheincodes im Stripe-Checkout sind bewusst deaktiviert. Diese Details sind unsichtbar, verhindern aber doppelte Abos und Rabatte, die an der Tariflogik vorbeilaufen.
Nach dem Checkout verarbeitet LaizyNote unter anderem diese Ereignisse:
- checkout.session.completed: ordnet den Kauf dem Nutzerkonto zu und aktiviert den Tarif.
- customer.subscription.created/updated/deleted: gleicht Abschluss, Tarifwechsel und Kündigungen ab.
- invoice.paid: dokumentiert eine erfolgreiche Zahlung.
- invoice.payment_failed: markiert das Zahlungsproblem für Oberfläche und weitere Verarbeitung.
Jede Zustellung wird zuerst gegen die Stripe-Signatur geprüft. Danach verlässt sich die Verarbeitung nicht auf das einzelne Ereignis, sondern liest alle Abonnements des Kunden erneut bei Stripe ein. Sind dabei mehrere gleichzeitig berechtigt, entscheidet der Code nicht stillschweigend für eines davon, sondern legt einen Integritätsalarm an.
Stripe ist die Quelle für den Zahlungszustand, aber nicht die Datenbank, aus der jede Ansicht der Anwendung live liest. Die Webhook-Verarbeitung übersetzt Stripe-Objekte in eine schlanke Zeile der Tabelle subscriptions. Darin stehen unter anderem Tarif, Abrechnungszyklus, aktuelle Periode, Kündigung zum Periodenende und Hinweise auf fehlgeschlagene Zahlungen.
Diese Projektion entkoppelt die Produktoberfläche von Stripe-Latenz und API-Limits. LaizyNote kann den Tarif zusammen mit den übrigen Kontodaten laden, ohne für jeden Seitenwechsel eine externe Abfrage auszuführen. Gleichzeitig bleibt nachvollziehbar, welche Stripe-Subscription und welcher Price dem Zustand zugrunde liegen.
Die Zuordnung Nutzer ↔ Stripe-Kunde ist ein eigener Fehlerfall. Checkout und Webhooks tragen die Nutzer-ID in den Metadaten, zusätzlich führt eine eigene Tabelle die Zuordnung von Stripe-Kunden zu Konten. Beim Umzug auf Supabase war genau das entscheidend: Ältere Abos tragen noch die frühere Nutzer-ID aus Firebase in ihren Metadaten und werden darüber weiterhin richtig zugeordnet. Auch alte oder gelöschte Stripe-Objekte werden abgefangen, damit ein veralteter Verweis keinen neuen Checkout dauerhaft blockiert.
Eine zentrale Funktion bestimmt den wirksamen Tarif
Der gespeicherte Tarif allein reicht nicht. LaizyNote berechnet einen wirksamen Tarif aus Abostufe, Stripe-Status und einem möglichen Bonuszugang. Diese Berechnung liegt als Funktion direkt in der Datenbank. Damit gilt sie für jede Abfrage und jede Zugriffsregel gleichermaßen – egal, ob der Zugriff aus der App, einer Edge Function oder einem geplanten Job kommt.
Die Status active und trialing sind eindeutig. Bei past_due oder unpaid bleibt der Tarif zunächst wirksam, während das Zahlungsproblem sichtbar markiert wird. Dadurch verschwinden Funktionen nicht sofort wegen eines abgelaufenen Zahlungsmittels. Beendete oder nicht berechtigte Abos fallen dagegen auf Free zurück.
Zeitlich begrenzte Bonuszugänge liegen als separate Ebene darüber. Ein Bonus kann einen Free-Nutzer vorübergehend auf Plus anheben, aber niemals einen bereits höheren Tarif herabsetzen. Sein Ablauf wird anhand eines Zeitstempels berechnet und hängt nicht davon ab, dass jemand den Datensatz manuell verändert.
-- Vereinfacht. Die Funktion liegt in der Datenbank, damit jede Abfrage
-- und jede Zugriffsregel denselben Tarif sieht.
create function app.effective_tier(p_user_id uuid) returns text
language sql stable security definer
as $$
with grundstufe as (
select
case
-- past_due und unpaid bleiben bewusst berechtigt: Stripe versucht noch.
when coalesce(trim(stripe_subscription_id), '') <> ''
and status not in ('active', 'trialing', 'past_due', 'unpaid')
then 'free'
else app.normalize_tier(tier)
end as stufe,
bonus_access_tier, bonus_access_expires_at
from public.subscriptions
where user_id = p_user_id
)
select coalesce((
select case
-- Ein Bonus hebt an, stuft aber nie herab – und läuft per Zeitstempel aus.
when bonus_access_expires_at > now()
and app.tier_order(bonus_access_tier) > app.tier_order(stufe)
then app.normalize_tier(bonus_access_tier)
else stufe
end from grundstufe
), 'free');
$$;Diese zentrale Berechnung verhindert, dass verschiedene Module eigene Interpretationen desselben Status entwickeln. Ohne sie könnte die Preisansicht einen Nutzer als Plus anzeigen, während eine Zugriffsregel nur das rohe Feld tier liest und anders entscheidet. Der wirksame Tarif ist deshalb eine kleine Funktion mit großer architektonischer Bedeutung.
Für Team-Workspaces kommt eine zweite Frage hinzu: Wessen Tarif gilt? LaizyNote behandelt nicht jedes eingeladene Mitglied wie einen Abonnenten. Kostenpflichtige Workspace-Funktionen orientieren sich am Eigentümer. Dessen Tarif liest eine eigene Datenbankfunktion, ohne dass Mitglieder Einblick in fremde Abodaten bekommen. Dadurch erhält ein Free-Nutzer nicht versehentlich Plus-Funktionen in seinem eigenen Bereich, nur weil er in einem anderen Team mitarbeitet.
Warum die Zugriffsregeln in der Datenbank sitzen
Das Frontend blendet Funktionen passend zum Tarif ein und verhindert beispielsweise das Anlegen weiterer Inhalte, sobald ein Free-Limit erreicht ist. Das ist wichtig für eine verständliche Bedienung, aber keine ausreichende Sicherheitsgrenze.
Bei LaizyNote sitzt diese Grenze in Postgres selbst. Jede Tabelle ist mit Row Level Security geschützt: Regeln, die bei jeder Abfrage festlegen, welche Zeilen ein Nutzer sehen und ändern darf. Für Inhalte wie Notizen gilt: eigene Einträge und die der Workspaces, in denen man Mitglied ist. Beim Anlegen prüft dieselbe Regel zusätzlich, ob das Kontingent des Tarifs noch reicht.
-- Anlegen nur im eigenen Bereich und im Rahmen des Tarif-Kontingents
create policy 'notes: anlegen im Rahmen des Kontingents'
on public.notes for insert to authenticated
with check (
case
when workspace_id is not null then
app.is_workspace_member(workspace_id)
and app.within_team_content_limit(workspace_id, 'notes')
else
owner_id = (select app.current_user_id())
and app.within_personal_content_limit('notes')
end
);Frontend-Prüfungen erklären dem Nutzer, was möglich ist. Die eigentliche Berechtigungsprüfung muss dort stattfinden, wo die geschützte Aktion ausgeführt wird. Sonst ließe sich der Aufruf direkt an die API senden. Liegt die Regel in der Datenbank, gibt es keinen Weg an ihr vorbei.
Die Grenzen selbst stehen in einer eigenen Tabelle und werden über Nutzungszähler geprüft, die Trigger bei jedem Anlegen und Löschen fortschreiben. Prüfung und Zählung passieren in derselben Transaktion. Ein nächtliches Audit, das Zähler nachträglich korrigiert, ist dadurch überflüssig geworden.
Wo eine Aktion mehr ist als ein Datensatz, prüft die zuständige Edge Function den wirksamen Tarif. Manuelle Backups stehen jedem Tarif offen, automatische Backups gibt es mit Plus. Automatisierungsregeln sind im Free-Tarif auf drei begrenzt. Daisys Verbrauch bucht eine einzige Datenbankfunktion: Sie prüft das Monatskontingent und zieht Credits ab, ohne dass zwei gleichzeitige Anfragen dasselbe Guthaben verbrauchen können.
Die Statuswechsel sind mit automatisierten Tests abgesichert. Geprüft werden nicht nur erfolgreiche Checkouts, sondern auch doppelte Requests, ungültige Preis-IDs, bestehende Abos, abgelaufene Bonuszugänge, Webhook-Wiederholungen und fehlgeschlagene Zahlungen. Gerade bei Abrechnungscode ist ein Test für den Normalfall zu wenig; entscheidend sind wiederholte und teilweise abgeschlossene Abläufe.
Webhook-Verarbeitung muss idempotent sein, weil Stripe dasselbe Ereignis erneut zustellen kann. Jedes Ereignis wird deshalb mit seiner ID in einer eigenen Tabelle festgehalten, bevor es verarbeitet wird. Kommt es erneut, ist es entweder schon erledigt – dann antwortet die Funktion sofort – oder noch in Arbeit; dann bittet sie Stripe um einen späteren Versuch. Eine hängengebliebene Verarbeitung gibt ihre Sperre nach 15 Minuten wieder frei. Für separat gekaufte Daisy-Credits dient die Stripe-Session zusätzlich als eindeutiger Buchungsschlüssel.
Kündigung ohne Datenverlust
Eine Kündigung bedeutet nicht sofort Free. Wenn Stripe cancel_at_period_end meldet, bleibt der bezahlte Tarif bis zum Ende des Abrechnungszeitraums aktiv. Erst danach setzt die Synchronisierung das Konto auf Free zurück.
Dabei werden keine Notizen, Aufgaben oder Projekte gelöscht. Liegt der Bestand oberhalb der Free-Limits, bleiben vorhandene Inhalte lesbar und nutzbar; lediglich neue Inhalte können nicht mehr hinzugefügt werden, bis Daten reduziert oder der Tarif wieder hochgestuft wird. Eine Kontolöschung ist bewusst ein anderer Vorgang und entfernt Daten sowie Backups endgültig.
Rechnungen, Zahlungsmethoden, Kündigung und Tarifwechsel verwaltet Stripe im Kundenportal. LaizyNote muss diese komplexen Zahlungsoberflächen dadurch nicht selbst nachbauen.
Die Grenze zwischen eigener Software und Zahlungsanbieter ist bewusst gezogen. LaizyNote entscheidet, welche Produktfunktion zu welchem Tarif gehört und wie ein Downgrade mit bestehenden Daten umgeht. Stripe entscheidet über Zahlungsmittel, Rechnungsbelege und Zahlungsversuche. Würde die Anwendung diese Aufgaben ebenfalls selbst übernehmen, müsste sie deutlich mehr finanzielle und rechtliche Zustände sicher abbilden.
Warum Webhooks allein nicht genügen
Webhooks sind der schnellste Weg, Stripe-Änderungen zu übernehmen. Trotzdem darf die Datenkonsistenz nicht davon abhängen, dass jedes einzelne Ereignis beim ersten Versuch ankommt. Deshalb gleicht ein täglicher Job alle gespeicherten Abonnements erneut mit Stripe ab. Er läuft als geplanter Datenbank-Job (pg_cron), nutzt dieselbe Logik wie der Webhook und stuft Abos herunter, die es bei Stripe nicht mehr gibt.
Ein zweiter Job verarbeitet abgelaufene Kündigungen und Bonuszugänge. Zusammen bilden sie das Sicherheitsnetz: Webhooks liefern schnelle Aktualität, die geplanten Abgleiche reparieren verpasste oder widersprüchliche Zustände.
Native Stripe-Architektur oder WooCommerce?
Beim SVE-Lizenzsystem übernimmt WooCommerce Subscriptions große Teile von Bestellung, Aboverwaltung und Benutzerzuordnung. Das war für ein eigenständiges Kalkulationstool mit WordPress-Backend wirtschaftlich sinnvoll.
LaizyNote ist dagegen selbst das vollständige SaaS-Produkt. Vue und Supabase bilden bereits Anwendung, Benutzerkonten und Datenmodell. Ein zusätzliches WordPress-System nur für die Abrechnung hätte eine zweite Benutzer- und Datenwelt geschaffen. Die direkte Stripe-Anbindung vermeidet diese Doppelstruktur, verlangt aber eigenen Code für Checkout, Webhooks, Abgleich, Tariflogik und Fehlerfälle.
| Stripe + Supabase | WooCommerce Subscriptions | |
|---|---|---|
| Passt zu | eigenständiger App mit eigenem Benutzerkonto | Produkt, bei dem WordPress ohnehin die Plattform ist |
| Benutzerverwaltung | bereits vorhanden (Supabase Auth) | WordPress-Benutzer, zweite Benutzerwelt |
| Abo-Logik | selbst gebaut: Webhooks, Tarifberechnung in SQL, Abgleich | weitgehend vom Plugin abgedeckt |
| Sonderfälle (Bonus, Limits, Teams) | frei modellierbar, direkt in Zugriffsregeln | an das Datenmodell des Plugins gebunden |
| Aufwand am Anfang | hoch | niedrig |
| Laufende Pflege | eigener Code, dafür keine Plugin-Updates | Plugin-, Theme- und WordPress-Updates |
| Was Stripe ohnehin übernimmt | Zahlungsmittel, Rechnungen, Zahlungsversuche, Kundenportal | |
- WooCommerce Subscriptions: schneller, wenn WordPress bereits die zentrale Plattform ist.
- Stripe + Supabase: direkter und flexibler für eine eigenständige Anwendung mit eigener Datenbank.
- Kein Ansatz ist pauschal besser: Entscheidend sind bestehender Stack, erwartete Sonderlogik und Wartungsaufwand.
Was ich beim zweiten Mal früher planen würde
Die technische Abrechnung würde ich wieder serverseitig um Stripe und einen zentral berechneten Tarif bauen. Anders würde ich vor allem den Zeitpunkt wählen: Tarife, Limits, Kündigungszustände, Bonuszugänge und das Verhältnis von Benutzer zu Workspace gehören früh in das Datenmodell – nicht erst kurz vor dem Launch.
Der Umzug auf Supabase hat das bestätigt. Weil Tarif und Grenzen von Anfang an an einer Stelle definiert waren, ließen sie sich als Datenbankfunktionen und Zugriffsregeln übertragen, statt über Dutzende Stellen verstreut gesucht werden zu müssen.
Der aufwendigste Teil ist nicht der Checkout. Es sind die Übergänge: bezahlt zu überfällig, aktiv zu gekündigt, Bonus zu regulärem Abo und Plus zurück zu Free, ohne Daten zu verlieren. Genau diese Übergänge machen aus einer Zahlungsintegration eine belastbare SaaS-Architektur. Solche Systeme sind ein Beispiel für meine Arbeit in der individuellen Web-Programmierung.
Häufige Fragen zur SaaS-Abrechnung
Wie wird ein LaizyNote-Abo aktiviert?
Nach einem erfolgreichen Stripe-Checkout prüft eine Edge Function die Signatur des Webhooks, liest die Abonnements des Kunden bei Stripe nach und speichert Tarif, Abrechnungszeitraum und Status in der Abo-Tabelle des Nutzers.
Was passiert bei einer fehlgeschlagenen Zahlung?
Der Zahlungsstatus wird gespeichert und in der Oberfläche angezeigt. Bei past_due und unpaid entzieht LaizyNote den Zugriff nicht sofort, während Stripe weitere Zahlungsversuche und die Abrechnung verwaltet.
Was passiert nach einer Kündigung?
Das Abo bleibt bis zum Ende des bezahlten Zeitraums aktiv. Danach fällt das Konto auf Free zurück; bestehende Inhalte bleiben erhalten, bei überschrittenen Free-Limits können jedoch keine weiteren Inhalte angelegt werden.
Warum reicht es nicht, Funktionen nur im Frontend auszublenden?
Frontend-Sperren verbessern die Bedienung, sind aber keine Sicherheitsgrenze. Bei LaizyNote prüft deshalb die Datenbank selbst über Zugriffsregeln (Row Level Security), was ein Nutzer lesen, ändern und anlegen darf. Aktionen wie Backups oder Daisy prüft zusätzlich die zuständige Edge Function.
Warum liegt die Tariflogik in der Datenbank?
Weil dann jede Abfrage und jede Zugriffsregel denselben wirksamen Tarif sieht, egal ob der Zugriff aus der App, einer Edge Function oder einem geplanten Job kommt. Es gibt keine zweite Stelle, an der der Tarif anders interpretiert werden könnte.
Was ist aufwendiger: Stripe direkt anbinden oder WooCommerce nutzen?
Der Einstieg ist mit WooCommerce Subscriptions schneller, weil Bestellung, Abo und Benutzerzuordnung mitgeliefert werden. Eine direkte Stripe-Anbindung kostet am Anfang mehr Zeit, ist aber flexibler, sobald eigene Tarifregeln, Limits oder Team-Logik dazukommen.
Wie wird verhindert, dass ein Nutzer versehentlich zwei Abos abschließt?
Die Checkout-Session entsteht ausschließlich serverseitig. Eine Edge Function prüft vorher, ob der Nutzer bereits ein aktives Abonnement hat; Änderungen laufen dann über das Kundenportal statt über einen zweiten Checkout. Zusätzlich greifen eine kurzlebige Sperre pro Nutzer in der Datenbank und Idempotency Keys, damit Doppelklicks oder wiederholte Requests kein zweites Stripe-Objekt erzeugen.
Planst du ein eigenes SaaS oder Lizenzsystem?
Ich entwickle Abrechnungs-, Zugriffs- und Produktlogik passend zu deinem bestehenden Stack – von Stripe und Supabase bis WooCommerce.
SaaS-Projekt besprechen