← Powrót do bloga
EngineeringBuild in PublicCennik

Sprzedawaliśmy plan, który nie działał

Wprowadziliśmy plan za 4,90 €/miesiąc. Opcja roczna pobierałaby 58,80 € każdego miesiąca, plan nie dawał żadnych uprawnień, a worker ignorował limit długości. Wszystkie trzy znalezione przez samodzielny zakup.

4 października 2026

Sprzedawaliśmy plan, który nie działał

Utworzono 4 października 2026 · Zaktualizowano 4 października 2026

Plan za 4,90 €/miesiąc pojawił się na naszej stronie z cenami. Każdy, kto wybrałby opcję roczną, zostałby obciążony kwotą 58,80 € każdego miesiąca.

Nikt nie został.

Wyłapaliśmy to, zanim choć jeden klient kupił plan roczny — nie czytając kodu, ale kupując produkt samodzielnie, prawdziwą kartą.

I to jest istotne. Build był zielony. TypeScript był zadowolony. API zwracało 200. Nic z tego nie znaczyło, że produkt działa.

Tego ranka znaleźliśmy trzy defekty. Nasz pierwszy płacący klient Solo wykupił subskrypcję dwadzieścia siedem minut po ostatniej z tych trzech poprawek. Pół godziny w drugą stronę i to on płaciłby za plan, który nie dawał mu niczego.

Defekt 1: cena była dobra, interwał nie

Nasz nowy plan wejściowy Solo kosztuje 4,90 €/miesiąc przy rozliczeniu rocznym albo 8,90 € miesięcznie. 4,90 € × 12 = 58,80 € raz w roku.

Cena w Stripe stojąca za opcją roczną została utworzona z miesięcznym interwałem rozliczeniowym.

Stripe był więc gotów pobierać 58,80 € każdego miesiąca — dwunastokrotność zamierzonej ceny.

Strona z cenami była poprawna. Kod używał właściwego identyfikatora ceny. Błąd żył poza repozytorium, w obiekcie Stripe utworzonym przez panel.

Właśnie to czyniło go łatwym do przeoczenia.

Stripe nie pozwala zmienić interwału rozliczeniowego istniejącej ceny: tworzy się nową cenę i podmienia identyfikator. Dodaliśmy więc sprawdzenie przed utworzeniem sesji płatności:

const expected = effectivePeriod === "annual" ? "year" : "month";
const price = await stripe.prices.retrieve(priceId);

if (price.recurring?.interval !== expected) {
  throw new Error("PRICE_INTERVAL_MISMATCH");
}

Jedno wywołanie API na płatność.

Jeśli Stripe i strona z cenami się nie zgadzają, odmawiamy sprzedaży.

Wadliwy obiekt Stripe nadal może istnieć. Po prostu nie przejdzie już niezauważony przez płatność.

Defekt 2: plan istniał handlowo, nie funkcjonalnie

Drugi błąd zawodził w przeciwnym kierunku: klient mógł nam zapłacić i nadal być traktowany jak darmowy.

Dodanie planu wygląda na zmianę handlową. W praktyce dotyka każdego miejsca w produkcie, które pyta: „co to konto ma prawo robić?"

W naszej bazie kodu odpowiedzi na to pytanie udzielano w 29 plikach.

Te sprawdzenia narastały z czasem w eksportach, uploadach, kolejce przetwarzania, punktach API, rozliczeniach i panelu. Większość stanowiły porównania łańcuchów napisane pod plany istniejące w chwili dodania tamtego kodu.

Solo było nowe.

Sprawdzenia typu czy to pro, creator albo studio? wpadały więc do gałęzi domyślnej. A gałąź domyślna była darmowa.

Oznaczało to, że płacące konto Solo mogło dostać darmowy limit, darmowe ograniczenia uploadu, znak wodny i pozostałe restrykcje poziomu darmowego.

Instynkt za tą wartością domyślną nie był głupi. W bezpieczeństwie „fail closed" to zwykle dokładnie to, czego się chce.

W rozliczeniach tworzy to jednak inny tryb awarii: klient już zapłacił, a wasze najbezpieczniejsze wyjście awaryjne staje się właśnie tym, co odmawia mu tego, co kupił.

Poprawką nie było dopisanie "solo" do 29 warunków. Zastąpiliśmy to jednym miejscem, w którym opisane jest zachowanie planu:

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

Nazwy powyżej są uproszczone na potrzeby artykułu, ale struktura jest ta, którą wdrożyliśmy.

Reguła na początku tego pliku mówi teraz: plan nie istnieje, dopóki nie jest tutaj.

Przy naprawie zdarzyły się dwa pouczające niedoszłe błędy.

Po pierwsze, prawie użyliśmy tego jednego sprawdzenia isPaid() do każdej bramki, także tych związanych z konkretnymi funkcjami. Dałoby to klientom Solo rzeczy, za które nie płacą, w tym dubbing AI i planowanie publikacji. „Płacący" i „uprawniony do tej funkcji" to nie to samo pytanie.

Po drugie, przez chwilę sądziliśmy, że planowanie nie ma żadnego sprawdzenia planu, bo szukanie "plan" w trasie nic nie zwracało. Bramka siedziała we wspólnej funkcji pomocniczej, o jedno wywołanie dalej.

Grep, który wraca pusty, nie jest odpowiedzią.

Defekt 3: limit, który ogłosiliśmy i nigdy nie wymusiliśmy

Strona z cenami mówi, że Solo przyjmuje materiały źródłowe do 60 minut. Worker, który pobiera i przetwarza wideo, znał tylko dwa limity:

Solo jest płatne. Więc Solo dostało 90.

Ten błąd wyciekał w drugą stronę. Nikogo nie obciążono zbyt dużo i nikt nie stracił dostępu. Po prostu rozdawaliśmy więcej mocy obliczeniowej, niż plan miał zawierać.

Dla produktu wideo to się liczy. Długość materiału źródłowego to jeden z czynników najbardziej bezpośrednio powiązanych z kosztem transkrypcji, renderowania i pasma.

Worker nosi teraz ten sam pułap co aplikacja:

def _max_minutes(plan: str | None) -> int:
    """Odbicie limitow planu uzywanych przez aplikacje.

    To, co oglasza strona z cenami, i to, co wymusza worker,
    musi byc ta sama liczba.

    Limit ogloszony bez wymuszenia to marza, ktora odchodzi;
    wymuszony bez ogloszenia to pulapka.
    """

Limit, który istnieje tylko na stronie z cenami, nie jest limitem. To zdanie na stronie.

Co faktycznie znalazło te błędy

Nie type checker. Nie zestaw testów. Nie przegląd kodu.

Znalazło je otwarcie strony tak, jakbyśmy jej nigdy nie widzieli, kliknięcie najtańszej opcji i wpisanie prawdziwej karty.

Rozbieżność w rozliczeniu pojawiła się w samym podsumowaniu płatności Stripe. Brakujące uprawnienia pojawiły się, gdy płacące konto nadal zachowywało się jak darmowe. Problem z długością pojawił się, gdy spytaliśmy workera, jaki limit faktycznie zastosuje.

Wciąż uczymy się tej samej lekcji: przechodzący build nie dowodzi, że produkt działa.

Każdy błąd żył w szczelinie między dwoma systemami, z których każdy robił dokładnie to, co mu powiedziano:

Tani sposób na znalezienie tej klasy błędów jest zaskakująco nudny: używać produktu tak, jak zrobi to osoba, która za niego płaci.

Epilog o atrybucji

Nasz pierwszy subskrybent Solo pojawił się tego samego ranka.

Przez jakąś godzinę opowiadaliśmy sobie ładną historię: Solo właśnie wyszło, ktoś znalazł je sam, uderzył w darmowy limit i konwertował.

Dziennik zdarzeń opowiadał coś mniej uporządkowanego.

Ta osoba znalazła Katto przez ChatGPT, zarejestrowała się, intensywnie korzystała z planu darmowego, wyczerpała limit, kilka razy otwierała płatność i odeszła bez zapłaty.

Następnego ranka wyszedł e-mail o porzuconej płatności. Kilka godzin później wykupiła subskrypcję.

Wybrała opcję 8,90 € miesięcznie zamiast 4,90 € przy rozliczeniu rocznym, płacąc 82% więcej miesięcznie, aby uniknąć dwunastomiesięcznego zobowiązania. Oferta założycielska, którą kliknęła i porzuciła dzień wcześniej, wynosiła 6 €.

Założyliśmy też, że to limit 60 minut na materiał źródłowy popchnął ją w stronę Solo. Nie. Tym, w co uderzała wciąż od nowa, był limit dwóch filmów na miesiąc.

Dwa założenia umarły więc naraz. Konwersja nie była po prostu organiczna — e-mail odzyskujący prawdopodobnie miał znaczenie. A limit wywierający największą presję na wykupienie planu nie był tym, za który go uważaliśmy.

Ma to dokładnie ten sam kształt co trzy błędy powyżej. Z zewnątrz łatwo było opowiedzieć czystą historię. Potem sprawdziliśmy, co naprawdę się stało.


Katto zamienia długie wideo w pionowe klipy. Publikujemy liczby, które mierzymy — także te, w których wypadamy źle.

Related articles

Gotowy, aby zamienić swoje filmy w wiralowe klipy?

Katto automatycznie wycina klipy, dodaje napisy i przekadrowuje Twoje długie filmy w treści krótkie.

Wypróbuj Katto za darmo →
Sprzedawaliśmy plan, który nie działał | Katto