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

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.

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 resultNa 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-runpokazuje, co by się zmieniło, i niczego nie zapisuje,- klucz produkcyjny Stripe,
sk_live_albork_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-keyalbo 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 problemsNajważ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.

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.
