Stripe sub: výměna selhané karty resetuje lokální trial a open invoice se neretryuje
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”Uživatel v trial subscription. První automatický charge selže (špatná karta, limit, 3DS odmítnuto). Uživatel přidá novou kartu. Backend řekne „aktivováno”, redirect. V DB se objeví nový 14denní trial_ends_at, sub_status='trial'. Reálně:
- Stripe customer má původní sub canceled s open invoice.
- A vedle toho nový sub s fresh trialem.
- Nikdo nic nezaplatil.
Při auditu desítek uživatelů najdeš stejný vzorec u ~8 % platících. Vedlejší stížnost: “nevidím tu novou kartu” — UI po submitu jen ukáže success a nezobrazí, která karta je default ani příští stržení.
Root cause
Sekce “Root cause”Tři propletené chyby v endpointu “aktivovat trial / nahrát novou kartu”:
1) Fallback now + 14 dní — pokud stripe.subscriptions.retrieve(...).trial_end je null (kdykoli kromě čerstvého trialu), kód lokálně dosadil new Date(Date.now() + 14d) jako trial_ends_at. Stripe sub mezitím není v trial vůbec — DB lže.
2) Žádný invoice retry — po stripe.subscriptions.update(sub, { default_payment_method }) u sub ve stavu incomplete/past_due Stripe SÁM open invoice neretryuje. Default PM se změní pro PŘÍŠTÍ účtování, ale ten otevřený požadavek (latest_invoice) zůstane v requires_payment_method.
3) “Druhý trial” check je slabý — kontrola alreadyHadTrial = !!profile.trial_ends_at selže, když webhook při canceled sub vynuluje profile.trial_ends_at (legitimní cleanup). Uživatel se vrátí, profil je čistý → kód vytvoří nový sub s trial_period_days: 14 → fake trial znovu. Klient se pak ptá “proč pořád v trial, kde jsou peníze”.
Vzájemně se násobí: (3) vyrobí druhý sub s trialem → (1) lokálně potvrdí fake trial_ends_at → původní open invoice z (2) tiše zůstane. Stripe nic “technicky” neporušil; aplikace mu jen řekla špatné instrukce a webhook handler zbytek skousnul.
Fix
Sekce “Fix”Čisté oddělení tří věcí:
1) Důvěřuj Stripe trial_end (nikdy si trial nevymýšlej):
trialEndsAt = sub.trial_end ? new Date(sub.trial_end * 1000).toISOString() : null; // ← bylo: new Date(Date.now() + 14*86400000).toISOString()2) Při výměně karty na nezaplaceném subu reálně retryuj invoice — klíčové: nestačí jen default_payment_method:
await stripe.subscriptions.update(subId, { default_payment_method: pmId });if (sub.status === 'incomplete' || sub.status === 'past_due') { const inv = sub.latest_invoice; if (inv?.status === 'open' && inv?.id) { try { await stripe.invoices.pay(inv.id, { payment_method: pmId, off_session: true }); } catch (e) { // 3DS / SCA → vrať UI hosted_invoice_url, ať uživatel klikne a potvrdí const fresh = await stripe.invoices.retrieve(inv.id); requiresActionUrl = fresh.hosted_invoice_url; } }}3) Druhý trial blokuj i přes Stripe historii (defense-in-depth proti webhookům, které nulují lokální flagy):
const allSubs = await stripe.subscriptions.list({ customer, status: 'all', limit: 10 });const hadAnyStripeSub = (allSubs.data || []).length > 0;const alreadyHadTrial = !!profile.trial_ends_at || hadAnyStripeSub;if (!alreadyHadTrial) subParams.trial_period_days = 14;Bonus — UI: když API vrátí requires_action_url, nepřesměrovávej tiše “aktivováno”. Místo toho ukaž tlačítko “Potvrdit platbu” odkazující na Stripe hosted invoice page. Uživatel ví, jestli mu peníze proběhly nebo musí dokončit 3DS.
Sanace existujícího stavu (read-only audit + selektivní akce na postižené customery):
voidstarý open invoice z canceled subu.stripe.subscriptions.update(currentSub, { trial_end: 'now' })→ Stripe vystaví novou fakturu a strhne z nové karty (s 3DS pokud banka vyžaduje).- Pošli hosted invoice URL e-mailem.
- DB backfill:
subscription_status='past_due',trial_ends_at=nullať systém nelže.
Jak se tomu vyvarovat v jiných systémech
Sekce “Jak se tomu vyvarovat v jiných systémech”-
Detection:
- Grep v platebním endpointu po
Date.now() +v kontextutrial_end/period_end→ red flag. - Audit: pro každého subscribera porovnej
profile.trial_ends_atsestripe.subscriptions.retrieve(sub).trial_end. Mismatch = bug. - Skenuj Stripe customery s >1 subscription a aktuální
openinvoice — ti jsou typicky postižení.
- Grep v platebním endpointu po
-
Anti-pattern:
- “Aktualizuji
default_payment_methoda tím jsem hotov” — Stripe to vždy nezachytí. Pro existující open invoice MUSÍŠ explicitně volatinvoices.pay(). - Spoléhání na jediný lokální flag pro “už měl trial”. Webhooky mohou flag nulovat (legitimně). Mít pravdu jen v jednom datasetu = bug.
- Tichý 200 OK po platbě, která vyžaduje 3DS. UI lže, uživatel si myslí, že platí, ve skutečnosti banka čeká.
- “Aktualizuji
-
Lepší přístup:
- Stripe je single source of truth pro vše billing (
trial_end,sub.status,invoice.status). Lokální profil je jen mirror. - Trial-eligibility kontroluj přes Stripe customer historii, ne přes lokální flagy.
- Při jakékoli platební akci, která může vyžadovat SCA, vždy zachyť výjimku
requires_actiona vrať UIhosted_invoice_url/payment_intent.client_secret. Žádné tiché success. - Periodický audit-skript: porovnej DB
subscription_statusse Stripe realitou; rozdíly hlas Slackem/e-mailem.
- Stripe je single source of truth pro vše billing (
Sister bugs / související
Sekce “Sister bugs / související”- Webhook
customer.subscription.deletedvynuluje lokální flagy (admin_approved,trial_ends_at, atd.) bez snapshotu → ztratíš dispozici “už měl trial” → druhý zdarma. - Off-session charge bez záchytu
requires_action→ tichý fail, peníze nikdy. - Stripe Customer Portal vs vlastní card-update flow → dva odlišné kódy dělají to samé jinak; jeden retryuje invoice, druhý ne.