SYS://FIELD-REPORT-14

You Charged the Card Once. Then What?

Checkout worked flawlessly. I subscribed with a test card, the webhook fired, access unlocked, the receipt landed. It’s a real, working payment flow — the AI tool nailed the part that’s genuinely hard to get wrong once Stripe is doing the heavy lifting. So I went looking for the second month. The webhook handler had exactly one branch: grant access on success. No branch for a renewal that fails. No way for a customer to cancel without emailing support. No handler, no alert, for a chargeback. Charging a card once is a demo. Everything after it is the business, and none of it was there.

This is a representative audit — the exact pattern I hit over and over on apps built with Lovable, Bolt, v0, and Cursor. Names and paths are changed, but every finding below is real-shaped and drawn straight from the kind of report the launchworthy skill produces. Call the app fitplan: a Next.js app on Stripe subscriptions, monthly plan, about to go live.

None of this throws an error. Nothing looks broken. That’s exactly why it leaks money quietly for months.

1
event type handled
0
failed-renewal branch
$0
recovered on decline

The finding: the handler only knows how to say yes

Here’s the whole switch, near enough. It grants on the happy path and stops:

switch (event.type) {
  case "checkout.session.completed":
  case "invoice.paid":
    await grantAccess(event)     // never revisited when next month's charge fails
    break
}

Renewal charges fail all the time — expired cards, insufficient funds, a bank declining a recurring transaction. With no invoice.payment_failed branch, that failure is silent: the customer either keeps access without paying, or gets cut off with no warning and churns angry. You don’t find out until you notice MRR drifting down for reasons you can’t name.

HIGH · CAUGHT

Webhook handles success but has no failed-renewal branch

app/api/webhooks/stripe/route.ts grants on checkout.session.completed / invoice.paid and handles nothing else. A failed renewal produces no email, no retry, no access change you chose — just a subscription that lapses in the dark. A payment integration that handles only the happy path is a demo that happens to move money.

The fix is a failure branch that lets Stripe run its retry schedule and acts on the terminal state, not on every failed attempt:

THE FIX

switch (event.type) {
  case "checkout.session.completed":
  case "invoice.paid":
    await grantAccess(event)
    break
  case "invoice.payment_failed":
    // one attempt failed; Stripe will retry on the dunning schedule
    await flagPastDue(event)     // surface it, email them — but don't cut access yet
    break
  case "customer.subscription.updated":
    // terminal states, after retries are exhausted
    const s = event.data.object.status
    if (s === "unpaid" || s === "canceled") await revokeAccess(event)
    break
}

The retry schedule and the dunning emails themselves are dashboard settings, not code: Stripe → Billing → Revenue recovery. Turn on Smart Retries and the failed-payment email sequence, and decide deliberately what happens when all retries fail — the default may keep a non-paying subscription alive forever. Then confirm the dunning email actually reaches customers (verified from-address, not landing in spam).

The other two leaks: no exit, no defense

Two more gaps, both cheap, both revenue.

No self-serve refund or cancellation. The checkout page linked to nothing — no refund policy, no cancellation path, no way to manage the subscription. “No refunds” is a policy you’re allowed to have; it is not one you’re allowed to hide until after the charge. And a customer who can’t find how to cancel doesn’t email you — they file a chargeback to make the charges stop, which costs you the revenue and a dispute fee.

THE FIX · 20 MIN

Link a refund/cancellation policy from the point of charge, and give customers Stripe’s hosted billing portal so they can cancel and update cards without you:

const session = await stripe.billingPortal.sessions.create({
  customer: customerId,
  return_url: `${origin}/account`,
})
return Response.json({ url: session.url })

A one-click cancel is the single biggest dispute reducer there is — someone who can stop the charges won’t dispute them.

No dispute handling. A chargeback starts a short clock — often about seven days — and an unanswered dispute is lost by default. fitplan had no charge.dispute.created handler and no alert, so the first anyone would hear of a dispute is a lost-balance line on the statement weeks later.

MEDIUM · CAUGHT

No alert when a dispute is filed

Add a handler that pages a human the moment a dispute opens, so you’re inside the response window:

case "charge.dispute.created":
  await alertTeam(event)     // Slack / email / PagerDuty — don't wait to notice the balance drop
  break

Then have the evidence ready to submit — proof of what was sold, the first-access timestamp, usage logs tying the account to the charge, the recorded ToS acceptance. Stripe’s dispute view has fields for exactly these; the point is the data exists and you can retrieve it under time pressure, not assemble it from scratch during the window. (This rises to HIGH if that same path also revokes access, because the money left with no notice.)

The false alarm: “Stripe handles all that”

This is the sentence that lets the whole lifecycle go unbuilt.

FALSE POSITIVE

⚠️ Stripe does subscriptions — retries, dunning, disputes are its job, not mine.

Stripe provides the machinery; it does not turn it on or wire it to your app for you. Smart Retries and dunning emails are off until you enable them in the dashboard and decide the end-of-retry behavior. The billing portal exists but does nothing until you create a session and link to it. Disputes have a response window, but Stripe won’t answer them — it just gives you the form and the clock. “Stripe handles it” is true for the plumbing and false for every decision and every code branch that connects the plumbing to your product. The failed-renewal branch, specifically, only lives in your code.

Verify by hand Use Stripe’s failed-renewal test card 4000 0000 0000 0341 and confirm the invoice.payment_failed branch fires and the dunning email arrives. Drive a subscription to the terminal unpaid/canceled state and confirm access is actually revoked. Open the billing-portal link as a test customer and cancel. Run stripe trigger charge.dispute.created and confirm the alert reaches a human. If any of those does nothing, that lifecycle stage is still unbuilt.

The rest of the punch list

The missing failed-renewal branch is the one that can’t ship. The rest is ranked so scope stays honest:

FIX THIS WEEK

After the renewal branch

  1. [MEDIUM] No refund/cancellation policy linked from checkout — add the link and the billing-portal button.
  2. [MEDIUM] No dispute alert — add the charge.dispute.created handler above.
  3. [MANUAL] Confirm Smart Retries + dunning emails are enabled in the dashboard and the from-address is verified. This one isn’t in the code; it’s a screenshot of your Revenue recovery settings.
  4. [LOW] No billing descriptor set, so charges show as a cryptic string on statements — a common trigger for “I don’t recognize this” disputes.

The shape is the honest part. One code branch decides whether failed renewals are recovered or lost silently; the rest is the difference between a payment integration and a payments business. Worst-first reads as “add the failure branch before launch, wire the portal and dispute alert this week.”

ASK YOURSELF

“Payments work — I tested it. Isn’t that the milestone?” You tested the first charge, which is the one part that’s easy to verify because it happens on your screen. The lifecycle is everything you can’t see in a demo: the renewal that fails next month, the customer who wants out, the dispute with a seven-day clock. None of it throws an error. All of it is money. The milestone isn’t “a card was charged” — it’s “the next twelve charges, and the exits, are handled.”

Why this keeps happening

The first payment is the part you watch happen. You click Subscribe, the card clears, access unlocks — the loop closes on your screen and it feels done. Everything after is invisible during a demo: renewals happen next month, declines happen to other people’s cards, disputes happen weeks later on a statement. AI builders generate the flow you can see and test, and the flow you can see is exactly the one month that works. The eleven months and the edge cases after it never come up while you’re building, so they never get built.

That’s the gap launchworthy exists to close. It plays bouncer at the door of production: it detects the payment provider, knows a webhook with no invoice.payment_failed branch is a HIGH, checks the checkout page for a policy link and the code for a dispute handler, and separates the code fixes from the dashboard settings you have to confirm yourself — instead of calling it done because a test card cleared.

The card was charged. The receipt sent. The demo closed clean. And month two, and the refund request, and the chargeback were all going to arrive with nobody home.