02Utrzymanie5 min

Stripe zepsuł mi build pięć razy i mam to już w kalendarzu

Podbicie wersji API Stripe'a to nie incydent. To cykliczne utrzymanie, z dość przewidywalnymi szkodami ubocznymi, i warto je zaplanować w budżecie.

Autor: Salva Castells

Zdjęcie okładkowe: Bernd Schwabe in Hannover, CC BY-SA 4.0.
W tym wpisie

Build wywala się na błędzie typów w lib/stripe.ts. Linia brzmi tak:

Type '"2025-06-30.basil"' is not assignable to type '"2025-07-30.basil"'.

Nie tknąłem niczego w płatnościach. Stało się to, że bun update przyciągnął nową wersję SDK Stripe'a, a paczka niesie w środku literał z wersją API, którą wspiera. W dniu, w którym ten literał się zmienia, wszystko, co przekazuje apiVersion jawnie, przestaje się kompilować. Zdarzyło mi się to pięć razy w nieco ponad rok, w czterech różnych projektach.

Za pierwszym razem był to incydent. Za piątym — coś, czego się spodziewałem.

Ten sam commit, w czterech repo, w odstępie roku

Sekwencja jest zapisana w historii i w swojej powtarzalności niemal komiczna. 2025-06-30.basil na 2025-07-30.basil w sierpniu. Miesiąc później 2025-07-30.basil na 2025-08-27.basil, w dwóch repo tego samego dnia. Miesiące później 2025-11-17.clover na 2026-01-28.clover w SaaS do generowania obrazów. I chwilę potem znowu, w boilerplate, od którego zaczynam projekty.

Komunikaty commitów są identyczne: "update Stripe API version to X". Diff też. I tu jest pierwsza lekcja, nijak niezwiązana ze Stripe'em: ten sam łańcuch znaków był powtórzony w czterech miejscach tego samego repo. Jeden w webhooku, i trzy kolejne w createCheckout, createCustomerPortal i findCheckoutSession, każdy z własnym new Stripe(secretKey, { apiVersion, typescript: true }). Cztery identyczne linie do ręcznej zmiany za każdym razem i cztery miejsca, w których można o jednej zapomnieć.

Miałem czelność zostawić przy każdej komentarz:

const stripe = new Stripe(secretKey, {
  apiVersion: "2026-01-28.clover", // TODO: update this when Stripe updates their API
  typescript: true,
});

TODO powtórzone cztery razy to dokumentacja tego, że wiesz, iż powtórzysz tę pracę, i mimo to jej nie scentralizowałeś.

Szkody uboczne nie siedzą w wersji, tylko w typach

Część, która psuje build, jest trywialna: zmieniasz łańcuch i się kompiluje. Naprawdę kosztowne są zmiany kształtu, przychodzące ukryte w tym samym podbiciu, bo SDK przepisuje przy okazji swoje typy.

Konkretny przypadek, który zjadł mi popołudnie, to webhook invoice.paid. Wyciągałem price ID tak:

const priceIdIP = stripeIP.lines.data[0].pricing?.price_details?.price;

W nowej wersji to pole staje się string | Price | undefined, zależnie od tego, czy przyszło rozwinięte, czy nie. TypeScript przestaje łykać porównanie z p.priceId, a co ważniejsze, runtime zaczyna porównywać obiekt z łańcuchem znaków: find po tabeli cen niczego nie znajduje, wpada w if (!pricingIP || !priceIdIP) i płatność nie zostaje zapisana w bazie. Opłata jest pobrana w Stripe, a użytkownik nie ma swoich kredytów.

Pierwsza poprawka była oczywista:

const rawPrice = stripeIP.lines.data[0].pricing?.price_details?.price;
const priceIdIP = typeof rawPrice === "string" ? rawPrice : rawPrice?.id;

I musiałem wrócić do tego tego samego dnia, bo rawPrice?.id nadal nie zawężał dobrze typu na wszystkich ścieżkach i kompilował się źle zależnie od trybu checkoutu. Wersja, która została, jest brzydsza i uczciwsza:

const priceIdIP =
  typeof rawPrice === "string"
    ? rawPrice
    : typeof rawPrice === "object" && rawPrice
      ? (rawPrice as { id: string }).id
      : undefined;

To samo z rabatami. discounts: couponId ? [{ coupon: couponId }] : [] współistnieje z allow_promotion_codes: true, i jest tam interakcja, której Stripe nie wybacza: jeśli podasz stały kupon, kody promocyjne klienta przestają się stosować. Dwa commity tego samego dnia, żeby to doprowadzić do porządku. Żaden z nich nie wynikał z wersji: wynikały z ruszania checkoutu.

Dlaczego w końcu przestałem przypinać wersję

Po piątym podbiciu zrobiłem odwrotnie, niż mówi ortodoksja. Skasowałem apiVersion z czterech miejsc:

const stripe = new Stripe(secretKey);

Bez apiVersion SDK używa wersji, którą ma wkompilowaną w środku. A to jest dokładnie ta wersja, względem której wygenerowano typy TypeScriptu, których używasz. Rozjazd psujący build znika z definicji: nie możesz mieć SDK w jednej wersji, a literału w drugiej, jeśli jest tylko jedno źródło.

Klasyczny argument za przypinaniem jest taki, że w ten sposób kontrolujesz, kiedy zmienia się zachowanie. To prawda, ale przestaje nią być, kiedy przypinasz wersję i jednocześnie aktualizujesz SDK co miesiąc. Wtedy nie masz dwóch dźwigni: masz jedną dźwignię i łańcuch znaków, który ręcznie synchronizujesz w czterech plikach. Prawdziwe przypięcie daje lockfile, nie literał.

Jeśli naprawdę chcesz przypiąć wersję API, przypięcie musi być w jednym miejscu, wyeksportowane, a SDK musi być zabite na amen w package.json z dokładną wersją, nie z ^. Przypięcie jednego i puszczenie drugiego luzem to najgorsze z obu światów.

Checklista na wyjście nowej wersji

  • Aktualizować SDK i literał naraz, nigdy osobno. Jeśli idą osobno, błąd typów wyskakuje w PR-ze, który o płatnościach nie mówi nic.
  • Szukać w całym repo, nie w oczywistym pliku. Pięć razy znalazłem piąte wystąpienie new Stripe( w miejscu, którego nie pamiętałem.
  • Przeczytać ponownie handlery webhooków, zanim uznasz zielony build za dobry. Kompilator ostrzeże cię o apiVersion, ale pole zmieniające się ze string w obiekt może się skompilować i wywalić w runtime, jeśli po drodze jest ?..
  • Przetestować invoice.paid przez CLI, nie tylko checkout.session.completed. Zdarzenia cykliczne idą inną ścieżką danych i psują się tak, że nikt się nie dowie do następnego miesiąca.
  • Sprawdzić kupon i kod promocyjny razem, jeśli sesja je niesie.

Moim błędem było traktowanie każdego podbicia jak przerwania. To nie jest przerwanie: nowa wersja wychodzi co dwa, trzy miesiące i kosztuje pół godziny, jeśli pamiętasz o zajrzeniu do webhooków, a popołudnie, jeśli nie. To jest utrzymanie, a utrzymanie się planuje. To, czego się nie zaplanuje, płaci się w postaci płatności, która weszła do Stripe'a i nie dotarła do bazy.