02Produkt5 min

Kredyty, saldo, limity wydatków

W pięciu produktach rozliczanie za użycie zbudowałem tak samo: zaczynam od kredytów, kończę na saldzie w centach i limitach na klucz. Już wiem, od którego etapu zaczynać.

Autor: Salva Castells

Zdjęcie okładkowe: Federico Candoni, CC BY-SA 4.0.
W tym wpisie

Kiedy produkt zużywa AI, pierwszą rzeczą, którą zawsze buduję, są kredyty. Liczba całkowita w tabeli profilu, odejmuje się jeden za generowanie i jedziemy. Zrobiłem tak w aplikacji do transkrypcji, w SaaS-ie do generowania obrazów i w boilerplacie, od którego startuję projekty. Wszystkie trzy razy skończyłem w tym samym miejscu: saldo w centach z częściami ułamkowymi i limity wydatków na klucz API.

Droga jest zawsze ta sama, więc już wiem, od którego etapu warto zacząć.

Dlaczego kredyty na początku zawsze wydają się wystarczające

Bo są, dopóki koszt operacji jest stały. Obraz kosztuje obraz. Jeden darmowy kredyt dziennie, dwadzieścia z subskrypcją, a check to jedna linia:

if (!profile || profile.subCredits < estimatedCost) {
  return NextResponse.json({ error: "Insufficient credits" }, { status: 402 });
}

Da się to zrozumieć na landingu bez tłumaczenia czegokolwiek. A jako liczba całkowita jest wygodne: nie ma zaokrągleń, użytkownik widzi "zostało ci 12" i nie pyta dalej.

Pierwszy sygnał, że model nie wystarcza, nie jest techniczny, tylko cenowy. W projekcie transkrypcyjnym liczyłem za znaki i dwa tygodnie po starcie podniosłem cenę za znak, mnożąc ją sześciokrotnie względem kosztu dostawcy, po policzeniu, ile znaków ma średnio zdanie po angielsku (około 75) i po hiszpańsku (około 90) oraz ile zdań wychodzi na pakiet za 2 dolary. Tego rachunku nie robisz na początku. Robisz go, kiedy masz już faktury od dostawcy i widzisz, że marża nie jest taka, jak myślałeś.

Gdzie to pęka, gdy tylko wchodzi realny koszt

Pęka w momencie, w którym produkt ma więcej niż jeden model. Jeśli użytkownik może wybrać między tanim a dokładnym modelem transkrypcji, kredyt przestaje cokolwiek znaczyć. Albo liczysz drogi jak tani i tracisz pieniądze, albo liczysz wszystko w cenie drogiego i tani nie ma sensu.

I pęka drugi raz przy dwóch pulach kredytów. W SaaS-ie z obrazami dodałem darmowe kredyty dzienne na wierzchu subskrypcyjnych i tego samego dnia musiałem naprawiać to, że nie dało się nic wygenerować: sprawdzenie nadal patrzyło tylko na subCredits. Naprawa polegała na zsumowaniu obu pul przed porównaniem.

const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);

Czytając to, wygląda oczywiście. Nie jest, kiedy nowa pula dochodzi w jednym commicie, a sprawdzenie mieszka w innym pliku.

Co rozwiązuje saldo, czego nie rozwiązywały kredyty

Saldo to liczba w centach, od której odejmuje się realny koszt każdej operacji. To pozwala mieszać modele, dostawców i różne jednostki (minuty audio i tokeny tekstu) na tym samym koncie.

Szczegół, którego się nie spodziewałem: całkowite centy nie wystarczą. Krótka transkrypcja tanim modelem kosztuje ułamek centa, a przy integer każde wywołanie zaokrąglało do zera i saldo nigdy nie spadało. Musiałem zmigrować kolumny na zmiennoprzecinkowe:

ALTER TABLE "usage_logs"   ALTER COLUMN "cost_cents"    SET DATA TYPE real;
ALTER TABLE "user_billing" ALTER COLUMN "balance_cents" SET DATA TYPE real;
ALTER TABLE "api_keys"     ALTER COLUMN "spend_limit_cents" SET DATA TYPE real;

Tak, pieniądze w real. Przy saldzie przedpłaconym, od którego tylko się odejmuje i które się doładowuje, to mi się opłaca: błąd zaokrąglenia jest nieistotny w porównaniu z utratą całej kwoty przez obcięcie. Gdybym fakturował z dołu, użyłbym liczb całkowitych w najmniejszej istniejącej jednostce.

Saldo zmienia też cennik: z "jednego planu" na trzy (4,99, 14,99 i 49,99 miesięcznie, wyrażone w godzinach transkrypcji i tokenach) plus pojedyncze doładowania po 5, 10 i 25 euro. Pod spodem wszystko jest tym samym: dodawaniem centów.

Przypadek skrajny: trzy modele rozliczania w jednym produkcie

W aplikacji do transkrypcji miałem w pewnym momencie trzy rzeczy naraz i nie wszystkie przetrwały:

  • Saldo. To, co zostało. Jeśli jest zero albo mniej, nic nie jest serwowane.
  • Miesięczny limit użytkownika. Pole monthly_limit_cents z domyślnym 4,99, edytowalne z poziomu konta.
  • Limit wydatków na klucz API. spend_limit_cents plus okres (daily, monthly, total), przeciwko sumie logów użycia tego klucza.

Miesięczny limit użytkownika skasowałem. ALTER TABLE "user_billing" DROP COLUMN "monthly_limit_cents" i precz z selektorem, akcją serwerową i tłumaczeniami w pięciu językach. Nic nie wnosił: przedpłacone saldo już jest limitem miesięcznym. Użytkownik, który wpłaca 20 euro, nie musi dodatkowo deklarować, że nie chce wydać więcej niż 20 euro. To był jeden ekran więcej do wytłumaczenia i jeden warunek więcej do sprawdzenia.

Co zostaje, to limit na klucz, bo tam jest realny przypadek: klucz wsadzony w skrypt, który wymyka się spod kontroli. I jest szczegół, którego nauczyłem się boleśnie: trzeba odczytać klucz z bazy po jego id ponownie, przed porównaniem, nawet jeśli masz już obiekt z uwierzytelnienia. Inaczej serwujesz żądania przeciwko przeterminowanemu spendLimitCents.

Guard trzeba wstawić we wszystkich endpointach, nie tylko w tych, które wydają. Te od listowania modeli i presetów też go mają, zwracając 429. Jeśli ktoś jest na zerze, niech całe API powie mu nie, a nie tylko droga część.

Jak decyduję dziś, pierwszego dnia

Jeśli produkt ma jedną operację o stałym koszcie — kredyty. Jeśli modeli jest więcej niż jeden albo koszt zależy od rozmiaru wejścia — saldo w centach z częściami ułamkowymi od pierwszego commita. Migracja później kosztuje popołudnie na schemacie i miesiąc maili tłumaczących użytkownikom, że ich kredyty to teraz euro.

I jeszcze jedno, niezależnie od modelu: kupon rabatowy z checkoutu wstawia się w stałą i testuje. Pewnego dnia zmieniłem couponId z "-100 euro" na "-100%" rano i cofnąłem to po południu. Dwa commity, ten sam plik, jedna linia.

Kredyty to decyzja marketingowa przebrana za techniczną. Saldo jest odwrotnie.