Wir haben einen Tarif verkauft, der nicht funktionierte
Wir haben einen Tarif für 4,90 €/Monat veröffentlicht. Die Jahresoption hätte 58,80 € jeden Monat abgebucht, der Tarif gewährte keine Rechte, und der Worker ignorierte sein Dauerlimit. Alle drei gefunden, indem wir ihn selbst gekauft haben.
4. Oktober 2026
Erstellt am 4. Oktober 2026 · Aktualisiert am 4. Oktober 2026
Ein Tarif für 4,90 €/Monat ging auf unserer Preisseite online. Wer die Jahresoption wählte, wäre mit 58,80 € jeden Monat belastet worden.
Niemand wurde es.
Wir haben es erwischt, bevor ein einziger Kunde den Jahrestarif gekauft hat — nicht durch Lesen des Codes, sondern indem wir das Produkt selbst mit einer echten Karte gekauft haben.
Das ist der Punkt. Der Build war grün. TypeScript war zufrieden. Die API gab 200 zurück. Nichts davon bedeutete, dass das Produkt funktionierte.
Wir fanden an diesem Morgen drei Defekte. Unser erster zahlender Solo-Kunde hat siebenundzwanzig Minuten nach dem letzten dieser drei Fixes abonniert. Eine halbe Stunde in die andere Richtung, und er hätte für einen Tarif gezahlt, der ihm nichts gewährt.
Defekt 1: der Preis war richtig, das Intervall nicht
Unser neuer Einstiegstarif Solo kostet 4,90 €/Monat bei jährlicher Abrechnung oder 8,90 € monatlich. 4,90 € × 12 = 58,80 € einmal im Jahr.
Der Stripe-Preis hinter der Jahresoption war mit einem monatlichen Abrechnungsintervall angelegt worden.
Stripe war also bereit, 58,80 € jeden Monat abzubuchen — zwölfmal den vorgesehenen Preis.
Die Preisseite war korrekt. Der Code benutzte die richtige Preis-ID. Der Fehler lebte außerhalb des Repositorys, in einem über das Dashboard erzeugten Stripe-Objekt.
Genau das machte ihn so leicht zu übersehen.
Stripe erlaubt es nicht, das Abrechnungsintervall eines bestehenden Preises zu ändern: man erzeugt einen neuen Preis und tauscht die ID. Wir haben deshalb eine Prüfung vor der Erzeugung der Checkout-Sitzung eingebaut:
const expected = effectivePeriod === "annual" ? "year" : "month";
const price = await stripe.prices.retrieve(priceId);
if (price.recurring?.interval !== expected) {
throw new Error("PRICE_INTERVAL_MISMATCH");
}
Ein API-Aufruf pro Checkout.
Wenn Stripe und die Preisseite sich widersprechen, verweigern wir den Verkauf.
Das fehlerhafte Stripe-Objekt kann weiterhin existieren. Es kommt nur nicht mehr unbemerkt durch den Checkout.
Defekt 2: der Tarif existierte kommerziell, nicht funktional
Der zweite Defekt versagte in die andere Richtung: ein Kunde konnte uns bezahlen und weiterhin als kostenlos behandelt werden.
Einen Tarif hinzuzufügen fühlt sich wie eine kommerzielle Änderung an. In der Praxis berührt es jede Stelle im Produkt, die fragt: „Was darf dieses Konto tun?"
In unserer Codebasis wurde diese Frage in 29 Dateien beantwortet.
Diese Prüfungen hatten sich über die Zeit angesammelt — in den Exporten, den Uploads, der Verarbeitungswarteschlange, den API-Endpunkten, der Abrechnung und im Dashboard. Die meisten waren String-Vergleiche, geschrieben gegen die Tarife, die existierten, als der Code entstand.
Solo war neu.
Prüfungen wie ist es pro, creator oder studio? fielen daher in den Standardzweig. Und der Standardzweig war der kostenlose.
Das bedeutete: ein zahlendes Solo-Konto konnte das Gratis-Kontingent, die Gratis-Uploadgrenzen, das Wasserzeichen und die übrigen Einschränkungen der kostenlosen Stufe bekommen.
Der Instinkt hinter diesem Standard war nicht dumm. In der Sicherheit ist „fail closed" meist genau das, was man will.
In der Abrechnung erzeugt es jedoch einen anderen Fehlermodus: der Kunde hat bereits bezahlt, und der sicherste Rückfall wird genau zu dem, was ihm das Gekaufte vorenthält.
Der Fix war nicht, "solo" zu 29 Bedingungen hinzuzufügen. Wir haben das durch eine einzige Stelle ersetzt, an der Tarifverhalten beschrieben wird:
export type Plan = "free" | "solo" | "pro" | "creator" | "studio";
export function isPaid(plan): boolean
export function hasDubbing(plan): boolean
export function hasScheduling(plan): boolean
export function videoQuota(plan, status): number
export function maxDurationMinutes(plan): number
export function maxUploadSize(plan): number
export function retentionHours(plan): number | null
Die Namen oben sind für den Artikel vereinfacht, aber die Struktur ist die, die wir ausgeliefert haben.
Die Regel am Kopf dieser Datei lautet jetzt: ein Tarif existiert nicht, solange er nicht hier steht.
Zwei lehrreiche Beinahe-Fehler bei der Korrektur.
Erstens hätten wir beinahe diese einzige isPaid()-Prüfung für jedes Gate verwendet, auch für die funktionsspezifischen. Das hätte Solo-Kunden Dinge gegeben, die sie nicht bezahlen — darunter KI-Vertonung und Beitragsplanung. „Zahlend" und „für diese Funktion berechtigt" sind nicht dieselbe Frage.
Zweitens glaubten wir kurz, die Planung habe überhaupt keine Tarifprüfung, weil die Suche nach "plan" in der Route nichts zurückgab. Das Gate steckte in einer gemeinsamen Hilfsfunktion, einen Aufruf entfernt.
Ein grep, das leer zurückkommt, ist keine Antwort.
Defekt 3: das Limit, das wir angekündigt und nie durchgesetzt haben
Die Preisseite sagt, Solo akzeptiere Quellvideos bis 60 Minuten. Der Worker, der Videos herunterlädt und verarbeitet, kannte nur zwei Grenzen:
- 30 Minuten für kostenlose Konten
- 90 Minuten für zahlende Konten
Solo ist zahlend. Also bekam Solo 90.
Dieser Defekt lief in die andere Richtung aus. Niemand wurde zu viel belastet und niemand verlor Zugang. Wir verschenkten einfach mehr Rechenzeit, als der Tarif enthalten sollte.
Für ein Videoprodukt zählt das. Die Quelldauer ist einer der Faktoren, die am direktesten mit den Kosten für Transkription, Rendering und Bandbreite zusammenhängen.
Der Worker trägt jetzt dieselbe Obergrenze wie die Anwendung:
def _max_minutes(plan: str | None) -> int:
"""Spiegel der Tarifgrenzen, die die Anwendung verwendet.
Was die Preisseite ankuendigt und was der Worker durchsetzt,
muss dieselbe Zahl sein.
Eine angekuendigte, nicht durchgesetzte Grenze ist Marge, die geht;
durchgesetzt ohne Ankuendigung ist sie eine Falle.
"""
Ein Limit, das nur auf der Preisseite existiert, ist kein Limit. Es ist ein Satz auf einer Seite.
Was diese Fehler tatsächlich gefunden hat
Nicht der Type-Checker. Nicht die Testsuite. Nicht ein Code-Review.
Gefunden hat sie: die Seite öffnen und so tun, als hätte man sie nie gesehen, die günstigste Option anklicken und eine echte Karte eingeben.
Die Abrechnungsabweichung erschien in Stripes eigener Checkout-Zusammenfassung. Die fehlenden Rechte erschienen, als sich das zahlende Konto weiterhin wie ein kostenloses verhielt. Das Dauerproblem erschien, als wir den Worker fragten, welche Grenze er tatsächlich anwenden würde.
Wir lernen immer wieder dieselbe Lektion: ein grüner Build beweist nicht, dass ein Produkt funktioniert.
Jeder Fehler lebte in der Lücke zwischen zwei Systemen, die einzeln genau taten, was man ihnen gesagt hatte:
- die Preisseite und Stripe
- der Name eines Tarifs und die Rechte, die er gewährt
- die Anwendung und der Worker
Der billige Weg, diese Fehlerklasse zu finden, ist überraschend langweilig: das Produkt so benutzen, wie es die Person tun wird, die dafür bezahlt.
Ein Epilog über Attribution
Unser erster Solo-Abonnent kam am selben Morgen.
Etwa eine Stunde lang erzählten wir uns eine schöne Geschichte: Solo war gerade erschienen, jemand hatte es allein gefunden, war ans Gratis-Limit gestoßen und hatte konvertiert.
Das Event-Log erzählte etwas weniger Aufgeräumtes.
Diese Person hatte Katto über ChatGPT gefunden, sich registriert, den kostenlosen Tarif intensiv genutzt, das Kontingent erreicht, den Checkout mehrfach geöffnet und war ohne Zahlung gegangen.
Am nächsten Morgen ging eine E-Mail zum abgebrochenen Checkout hinaus. Einige Stunden später abonnierte sie.
Sie wählte die Option zu 8,90 € monatlich statt 4,90 € bei Jahresabrechnung und zahlte damit 82 % mehr pro Monat, um eine Zwölfmonatsbindung zu vermeiden. Das Founding-Angebot, das sie am Tag davor angeklickt und verlassen hatte, lag bei 6 €.
Wir hatten außerdem angenommen, das 60-Minuten-Limit für Quellvideos habe sie zu Solo gedrängt. Falsch. Woran sie immer wieder stieß, war das Kontingent von zwei Videos pro Monat.
Zwei Annahmen starben also gleichzeitig. Die Konversion war nicht einfach organisch — die Recovery-Mail hat wahrscheinlich eine Rolle gespielt. Und das Limit mit dem stärksten Upgrade-Druck war nicht das, für das wir es hielten.
Das hat genau die Form der drei Fehler oben. Von außen ließ sich leicht eine saubere Geschichte erzählen. Dann haben wir nachgesehen, was wirklich passiert ist.
Katto verwandelt lange Videos in vertikale Clips. Wir veröffentlichen die Zahlen, die wir messen — auch die, die uns schlecht aussehen lassen.
Ähnliche Artikel
Bereit, deine Videos in virale Clips zu verwandeln?
Katto schneidet, untertitelt und rahmt deine langen Videos automatisch zu Kurzform-Content um.
Katto kostenlos testen →