El build cau amb un error de tipus a lib/stripe.ts. La línia és aquesta:
Type '"2025-06-30.basil"' is not assignable to type '"2025-07-30.basil"'.
No he tocat res de pagaments. El que ha passat és que bun update s'ha endut una versió nova del SDK de Stripe, i el paquet porta a dins un literal amb la versió d'API que suporta. El dia que aquell literal canvia, tot el que passa apiVersion explícitament deixa de compilar. Això m'ha passat cinc vegades en poc més d'un any, en quatre projectes diferents.
La primera vegada va ser un incident. La cinquena ja era una cosa que esperava.
El mateix commit, en quatre repos, amb un any de separació
La seqüència està escrita a l'historial i és gairebé còmica de tan repetitiva. 2025-06-30.basil a 2025-07-30.basil a l'agost. Un mes després, 2025-07-30.basil a 2025-08-27.basil, en dos repos el mateix dia. Mesos més tard, 2025-11-17.clover a 2026-01-28.clover en un SaaS de generació d'imatges. I poc després, una altra vegada, al boilerplate que faig servir per començar projectes.
Els missatges de commit són idèntics: "update Stripe API version to X". El diff també. I aquí hi ha la primera lliçó, que no té res a veure amb Stripe: la mateixa cadena estava repetida en quatre llocs del mateix repo. Una al webhook, i tres més a createCheckout, createCustomerPortal i findCheckoutSession, cadascuna amb el seu propi new Stripe(secretKey, { apiVersion, typescript: true }). Quatre línies idèntiques que cal canviar a mà cada vegada, i quatre llocs on oblidar-se'n d'una.
Vaig tenir la barra de deixar un comentari al costat de cadascuna:
const stripe = new Stripe(secretKey, {
apiVersion: "2026-01-28.clover", // TODO: update this when Stripe updates their API
typescript: true,
});
Un TODO repetit quatre vegades és documentació que saps que repetiràs la feina i tot i així no l'has centralitzada.
Els danys col·laterals no són a la versió, són als tipus
La part que trenca el build és trivial: canvies la cadena i compila. El que costa de debò són els canvis de forma que vénen amagats a la mateixa pujada, perquè el SDK reescriu els seus tipus alhora.
El cas concret que em va menjar una tarda va ser el webhook d'invoice.paid. Jo treia el price ID així:
const priceIdIP = stripeIP.lines.data[0].pricing?.price_details?.price;
Amb la versió nova, aquell camp passa a ser string | Price | undefined segons vingui expandit o no. TypeScript deixa d'empassar-se la comparació contra p.priceId i, l'important, el runtime comença a comparar un objecte contra una cadena: el find sobre la taula de preus no troba res, cau al if (!pricingIP || !priceIdIP) i el pagament no s'aplica a la base de dades. El cobrament està fet a Stripe i l'usuari no té els seus crèdits.
El primer arreglament va ser l'evident:
const rawPrice = stripeIP.lines.data[0].pricing?.price_details?.price;
const priceIdIP = typeof rawPrice === "string" ? rawPrice : rawPrice?.id;
I el vaig haver de tornar a tocar el mateix dia, perquè rawPrice?.id continuava sense estrènyer bé el tipus en tots els camins i compilava malament segons el mode de checkout. La versió que va quedar és més lletja i més honesta:
const priceIdIP =
typeof rawPrice === "string"
? rawPrice
: typeof rawPrice === "object" && rawPrice
? (rawPrice as { id: string }).id
: undefined;
El mateix amb els descomptes. discounts: couponId ? [{ coupon: couponId }] : [] conviu amb allow_promotion_codes: true, i allà hi ha una interacció que Stripe no perdona: si passes un cupó fix, els codis promocionals del client deixen d'aplicar-se. Dos commits el mateix dia per deixar-ho bé. Cap dels dos no va ser per la versió: van ser per tocar el checkout.
Per què al final vaig deixar de fixar la versió
Després del cinquè bump vaig fer el contrari del que diu l'ortodòxia. Vaig esborrar l'apiVersion dels quatre llocs:
const stripe = new Stripe(secretKey);
Sense apiVersion, el SDK fa servir la versió que té compilada a dins. I aquesta és exactament la versió contra la qual estan generats els tipus de TypeScript que estàs fent servir. El desajust que trencava el build desapareix per construcció: no pots tenir el SDK en una versió i el literal en una altra si només hi ha una font.
L'argument clàssic per fixar-la és que així controles quan canvia el comportament. És cert, però deixa de ser-ho quan fixes la versió i alhora actualitzes el SDK cada mes. Allà no tens dues palanques: tens una palanca i una cadena que sincronitzes a mà en quatre fitxers. El pin real el dona el lockfile, no el literal.
Si de debò vols pinnar la versió d'API, el pin ha de ser en un sol lloc, exportat, i el SDK ha d'estar clavat al package.json amb versió exacta, no amb ^. Fixar-ne una i deixar l'altra flotant és el pitjor dels dos mons.
El checklist de quan surt versió nova
- Actualitzar el SDK i el literal alhora, mai per separat. Si van separats, l'error de tipus apareix en una PR que no parla de pagaments.
- Buscar a tot el repo, no al fitxer obvi. Cinc vegades he trobat una cinquena instància de
new Stripe(en un lloc que no recordava. - Rellegir els handlers de webhook abans de donar per bo el build verd. El compilador t'avisa de l'
apiVersion, però un camp que passa destringa objecte pot compilar i fallar en runtime si hi ha un?.pel mig. - Provar
invoice.paidamb el CLI, no noméscheckout.session.completed. Els esdeveniments recurrents fan servir una altra ruta de dades i es trenquen sense que ningú se n'assabenti fins al mes següent. - Comprovar el cupó i el codi promocional junts si la sessió els porta.
El meu error va ser tractar cada bump com una interrupció. No ho és: surt una versió cada dos o tres mesos i costa mitja hora si te'n recordes de mirar els webhooks, una tarda si no. Això és manteniment, i el manteniment s'agenda. El que no s'agenda es paga en forma d'un cobrament que va entrar a Stripe i no va arribar a la base de dades.
