Přeskočit na obsah

Stripe sub: výměna selhané karty resetuje lokální trial a open invoice se neretryuje

import { Aside } from ‘@astrojs/starlight/components’;

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í.

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.

Č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):

  • void starý 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=null ať 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 kontextu trial_end / period_end → red flag.
    • Audit: pro každého subscribera porovnej profile.trial_ends_at se stripe.subscriptions.retrieve(sub).trial_end. Mismatch = bug.
    • Skenuj Stripe customery s >1 subscription a aktuální open invoice — ti jsou typicky postižení.
  • Anti-pattern:

    • “Aktualizuji default_payment_method a tím jsem hotov” — Stripe to vždy nezachytí. Pro existující open invoice MUSÍŠ explicitně volat invoices.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á.
  • 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_action a vrať UI hosted_invoice_url / payment_intent.client_secret. Žádné tiché success.
    • Periodický audit-skript: porovnej DB subscription_status se Stripe realitou; rozdíly hlas Slackem/e-mailem.

Sister bugs / související

Sekce “Sister bugs / související”
  • Webhook customer.subscription.deleted vynuluje 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.
Přidal aiarchitekt.cz · 28. 5. 2026 2:00
Provozuje aiarchitekt.cz