MOVGRANTO

Cennik w bazie, ceny w Stripe: synchronizacja komendą

Cennik BusiKM jest w bazie, a Stripe trzyma własne, niezmienne ceny. Jedna komenda zakłada je idempotentnie, druga tylko czyta i sprawdza zgodność.

Opublikowano: 6 min czytania

Autor: Łukasz Głowacz, Full-Stack Engineer · Read in English

Cennik PlanPrice w bazie danych, z którego komenda zakłada produkty i ceny w Stripe.
Spis treści

W BusiKM cennik jest w bazie danych, a płatności obsługuje Stripe. Stripe nie czyta mojej bazy, tylko trzyma własne rekordy produktów i cen, a aplikacja wskazuje je identyfikatorami w zmiennych środowiska. Katalog testowy w Stripe jest oddzielony od produkcyjnego, a każde środowisko ma własne zmienne. W tym poście opisuję, jak utrzymuję cennik w bazie i ceny w Stripe w zgodzie: jedna komenda zakłada katalog, druga tylko czyta i mówi, czy konfiguracja się zgadza.

Dlaczego cennik nie jest stałą w kodzie

Cennik trafił do bazy we wrześniu, jako model PlanPrice. Uzasadnienie zapisałem w samym modelu: zmiana ceny nie może wymagać deployu, a stare subskrypcje muszą dać się rozliczyć po cenie z dnia zakupu. Dlatego wiersz cennika ma valid_from i valid_to i nikt go nie nadpisuje. Nowa cena to nowy wiersz.

Stripe ma podobną zasadę, tylko ostrzejszą. Kwoty istniejącej ceny nie da się zmienić, można założyć nową i wyłączyć starą. Mam więc dwa źródła, oba niezmienne, i pięć identyfikatorów, które je łączą: cztery ceny planów, Start i Firma, miesięcznie i rocznie, oraz dopłatę za pojazd ponad pulę planu.

Integracja ze Stripe weszła pod koniec września, ale kod niczego w Stripe nie zakładał. Ceny trzeba było utworzyć w panelu i przepisać ich identyfikatory do zmiennych, a jedyną kontrolą była lista brakujących ustawień w pulpicie administratora. Lista mówiła, czy zmienna jest ustawiona, ale nie mówiła, czy wskazuje właściwą kwotę.

Klucz, który łączy cennik z Stripe

21 października doszła komenda stripe_sync_catalog. Źródłem prawdy jest aktywny cennik w bazie, a Stripe dostaje jego kopię. Żeby komenda wiedziała, która cena w Stripe odpowiada któremu wierszowi, każda cena ma stały klucz lookup_key w postaci busikm_<plan>_<billing_period>, na przykład busikm_company_yearly.

Przepływ: cennik PlanPrice w bazie czyta komenda stripe_sync_catalog, która zakłada w Stripe produkty, ceny i webhook, a identyfikatory cen wypisuje jako zmienne środowiska dla usług web i worker. Komenda stripe_check_config tylko czyta i porównuje zmienne z cenami w Stripe i z cennikiem

Dzięki temu kluczowi komenda jest idempotentna. Drugie uruchomienie niczego nie zmienia. Gdy kwota w cenniku się zmieni, komenda zakłada nową cenę, przenosi na nią klucz i wyłącza starą:

def sync_catalog(client, *, dry_run: bool) -> SyncResult:
    """Brings Stripe products and prices in line with the PlanPrice table."""
    desired = desired_catalog()  # plan x period from PlanPrice, plus the vehicle overage
    existing = {p["lookup_key"]: p for p in list_prices(client, desired)}
    for item in desired:
        product_id = ensure_product(client, item, dry_run)
        current = existing.get(item.lookup_key)
        if current and not price_mismatches(current, item) and current["product"] == product_id:
            result.settings[item.setting] = current["id"]  # matching price, nothing to do
            continue
        if dry_run:
            continue
        created = client.Price.create(
            product=product_id, currency=item.currency, unit_amount=item.unit_amount,
            tax_behavior="exclusive", recurring={"interval": item.interval},
            lookup_key=item.lookup_key,
            transfer_lookup_key=current is not None,  # the key moves to the new price
        )
        if current is not None:
            client.Price.modify(current["id"], active=False)
        result.settings[item.setting] = created["id"]
    return result

Na koniec komenda wypisuje gotowe linie STRIPE_PRICE_*=price_... do skopiowania w zmienne środowiska. Ta sama komenda z parametrem --webhook-url zakłada albo poprawia endpoint webhooka z dokładnie tymi pięcioma zdarzeniami, które backend obsługuje. Lista zdarzeń jest brana z kodu obsługi webhooka, więc po dodaniu nowego zdarzenia wystarczy uruchomić komendę ponownie, bez przepisywania listy w panelu Stripe.

Bezpieczniki

Komenda zapisuje w zewnętrznym systemie płatności, więc ma trzy zabezpieczenia:

  • --dry-run pokazuje, co by się zmieniło, i niczego nie zapisuje,
  • klucz produkcyjny Stripe, sk_live_ albo rk_live_, jest odrzucany, dopóki nie dopiszę --live, więc pomyłka w kluczu nie dotknie prawdziwych płatności,
  • klucz przychodzi przez parametr --api-key albo ze zmiennej środowiska, a moduł katalogu nie korzysta z globalnej konfiguracji, tylko dostaje gotowego klienta. Dzięki temu testy podstawiają atrapę Stripe i nic nie wychodzi do sieci.

Jest też jedna reguła biznesowa pilnowana w kodzie. Stripe ma jedną cenę dopłaty za pojazd, więc jeśli w cenniku plany dostaną różne dopłaty, komenda kończy się błędem, zamiast po cichu wybrać jedną z nich.

Kontrola, która tylko czyta

Druga komenda, stripe_check_config, niczego nie zakłada. Dla każdej ceny z cennika sprawdza, czy zmienna jest ustawiona, czy wskazuje istniejącą cenę w Stripe i czy ta cena się zgadza. Na końcu zwraca kod wyjścia 1, jeśli cokolwiek się rozjeżdża, więc nadaje się do skryptu i do CI.

def price_mismatches(price, desired: CatalogPrice) -> list[str]:
    """Differences between a Stripe price and the pricing table, empty when it matches."""
    problems = []
    if not price.get("active"):
        problems.append("price is inactive")
    if price.get("currency") != desired.currency:
        problems.append(f"currency {price.get('currency')} instead of {desired.currency}")
    if price.get("unit_amount") != desired.unit_amount:
        problems.append(f"net amount {price.get('unit_amount')} instead of {desired.unit_amount}")
    if (price.get("recurring") or {}).get("interval") != desired.interval:
        problems.append("wrong billing interval")
    if price.get("tax_behavior") != "exclusive":
        problems.append("wrong tax_behavior")
    return problems

Najważniejsze jest to, że obie komendy używają tej samej funkcji price_mismatches. Synchronizacja i kontrola mają jedną definicję zgodnej ceny, więc nie może się zdarzyć, że synchronizacja uzna cenę za dobrą, a kontrola za złą. Kontrola tylko czyta, więc można ją bezpiecznie uruchomić na każdym środowisku, także tam, gdzie synchronizacji bez --live uruchomić się nie da.

Atrapa, która przepuszczała błąd

Komenda miała 16 testów i wszystkie przechodziły na atrapie Stripe. Atrapa zwracała zwykłe słowniki, a kod czytał pola przez price.get("active"). Tymczasem od wersji 15 biblioteki stripe-python obiekty z API nie są słownikami i .get() rzuca błąd. Kod działał więc tylko w testach.

Ten sam kod daje dwa wyniki: w teście atrapa zwraca słownik, więc price.get działa i testy są zielone, a prawdziwa biblioteka stripe 15 zwraca StripeObject, na którym get rzuca błąd. Poprawka: funkcja as_plain w kodzie i atrapa zwracająca StripeObject tak jak biblioteka

Poprawka miała dwie części i druga była ważniejsza. W katalogu, kontroli i naliczaniu dopłaty każdy obiekt ze Stripe przechodzi przez as_plain, które zamienia go rekurencyjnie na słownik. A atrapa zwraca teraz prawdziwy obiekt biblioteki, a nie słownik, więc ten sam błąd nie przejdzie już przez testy:

def _api_object(values: dict):
    """Like the real library since v15: an API object, not a dict (.get raises)."""
    return stripe.StripeObject.construct_from(dict(values), "sk_test_fake")

To ta sama lekcja, którą opisałem w poście o testach E2E na prawdziwym backendzie, tylko w mniejszej skali. Atrapa sprawdza Twój kod tylko w tych miejscach, w których zachowuje się jak prawdziwa zależność. Jeśli zwraca wygodniejszy typ niż biblioteka, testy sprawdzają kod, którego nikt nie uruchomi.

Decyzje biznesowe, które zmieniają konfigurację

Przy okazji wyszły dwie rzeczy, w których Stripe wymusza decyzję produktową.

Pierwsza to dopłata za pojazd. Plan roczny z miesięczną dopłatą za każdy pojazd ponad pulę to subskrypcja o mieszanych okresach rozliczenia. Stripe przyjmuje taką tylko w trybie billing_mode: flexible. Bez niego dodanie pozycji dopłaty do planu rocznego kończyłoby się błędem, więc Checkout zakłada subskrypcje od razu w tym trybie.

Druga to VAT. Sprzedawca abonamentu, MOVGRANTO, korzysta ze zwolnienia podmiotowego z VAT, więc ceny z cennika są kwotami końcowymi, a faktura ma stawkę „zw.” z adnotacją o podstawie zwolnienia. Stripe nie nalicza podatku, bo automatyczny VAT jest wyłączony zmienną STRIPE_AUTOMATIC_TAX. Stawka VAT w cenniku zostaje na przyszłość. Po rejestracji jako czynny podatnik VAT wystarczy przełączyć dwie zmienne, bez migracji cennika i bez zmiany cen w Stripe.

Ile to kosztuje

Komenda nie ustawia zmiennych sama. Wypisuje je, a ja kopiuję je do usług web i worker i robię redeploy. To jeden ręczny krok, a jego ceną jest to, że po zmianie kwoty w cenniku trzeba pamiętać o podmianie identyfikatora. Kontrola konfiguracji wyłapie, jeśli zapomnę, ale tylko wtedy, gdy ją uruchomię.

Druga rzecz to granica tego, co Stripe wie. Stripe dostaje tylko kwoty. Limity pojazdów w planach żyją w cenniku w bazie i Stripe ich nie zna. Pisząc runbook dla stagingu, znalazłem rozjazd między cennikiem w bazie a dokumentem modelu biznesowego: inne limity pojazdów w obu planach. Synchronizacja tego nie wykryła, bo nie miała czego porównać. Dokument poprawiłem tak, żeby zgadzał się z kodem.

Najwięcej dało mi jednak rozdzielenie zapisu od kontroli. Komenda, która zapisuje, ma bezpieczniki i wymaga zgody na klucz produkcyjny. Komenda, która tylko czyta, może działać wszędzie i często. To ten sam pomysł co w kontrakcie API między trzema repozytoriami: jedno źródło prawdy i automatyczna kontrola rozjazdu tam, gdzie kopia żyje poza moim kodem. Jeśli budujesz płatności w swoim SaaS i chcesz porozmawiać o tym, gdzie powinien leżeć cennik, napisz do mnie.

Łukasz Głowacz

O autorze

Łukasz Głowacz

Full-Stack Engineer · MOVGRANTO

Full-Stack Engineer w MOVGRANTO. Buduje systemy od architektury po produkcję: backend, web, mobile, płatności i CI/CD. Autor BusiKM, SaaS do zarządzania flotą busów.

LinkedInGitHub

BusiKM

Zarządzanie flotą busów 2,5-3,5 t: zlecenia, mapa floty, czas pracy kierowców i koszty w jednym miejscu. 14 dni za darmo.

Sprawdź BusiKM ↗

Budujesz podobny system?

Dołączamy do zespołów jako inżynierowie full-stack albo budujemy produkt od zera. Porozmawiajmy o Twoim projekcie.

Umów rozmowę →

Newsletter

Co tydzień jedna prawdziwa decyzja architektoniczna z budowy produkcyjnego SaaS.