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ę zestringw obiekt może się skompilować i wywalić w runtime, jeśli po drodze jest?.. - Przetestować
invoice.paidprzez CLI, nie tylkocheckout.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.
