Skip to content

Keep Pro through failed-payment retries + dunning emails - #2074

Merged
richiemcilroy merged 2 commits into
mainfrom
feat/involuntary-churn-rescue
Aug 4, 2026
Merged

Keep Pro through failed-payment retries + dunning emails#2074
richiemcilroy merged 2 commits into
mainfrom
feat/involuntary-churn-rescue

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Aug 4, 2026

Copy link
Copy Markdown
Member

Why

Failed payments currently get the harshest possible treatment, which makes them a meaningful and avoidable source of churn:

  • userIsPro() excluded past_due, so access dropped on the first failed charge with no grace window.
  • The billing card fell through to "Upgrade to Cap Pro" for past_due owners, as if they were never subscribed.
  • invoice.payment_failed was not in the webhook's relevant events (the handler 400s it), and no billing email of any kind existed.
  • On the transition to past_due, the subscription.updated handler recomputed inviteQuota from active-only subs, collapsing the org's seats to zero mid-dunning.

What

  1. Grace period: past_due retains Pro entitlement (userIsPro, isAiGenerationEnabledForUser) until Stripe exhausts retries and moves the sub to canceled/unpaid, which is when access actually drops. The grace window is therefore bounded by the Stripe retry schedule, no timer of our own.
  2. Billing UI: past_due renders a "Payment failed" state on the billing card (red badge + fix-your-card line) instead of the upgrade pitch. getSubscriptionDetails passes past_due through.
  3. Dunning emails: new payment-failed email template (matches existing email styling); webhook handles invoice.payment_failed for subscription_cycle/subscription_update invoices, emailing on the first failure and the final retry only. Idempotent via Resend idempotency keys, so Stripe webhook retries can't double-send.
  4. Quota fix: inviteQuota now counts seats from all entitled statuses (active, trialing, past_due). Behavior note: trialing subs previously did not count toward quota; they now do, consistent with them having Pro entitlement.

Activation (after deploy)

The production Stripe webhook endpoint must have invoice.payment_failed added to its enabled events once this is live, otherwise the dunning path never fires. Deliberately not done yet so events don't hit an endpoint that 400s them.

Testing

  • New unit tests for the entitlement grace behavior (pro-entitlement-grace.test.ts), full apps/web unit suite run: 1290 passed, 1 pre-existing unrelated failure (slack-app-manifest.test.ts color constant, also failing on main).
  • tsc clean on apps/web and packages/utils.

Immediate effect on merge

Existing past_due subscriptions regain Pro access and see the fix-your-card state instead of the upgrade pitch.

…etry window

A past_due subscription previously lost Pro on the first failed charge and
the billing card fell back to the Upgrade to Pro state as if no subscription
existed. past_due now retains entitlement until Stripe exhausts retries and
moves the sub to canceled/unpaid, and the billing card shows an explicit
payment-failed state pointing at the payment method fix.
Handle invoice.payment_failed for renewals and plan changes: email on the
first failure and the final retry (idempotent via Resend idempotency keys),
linking to billing settings. Also compute inviteQuota from all entitled
subscription statuses (active, trialing, past_due) so an org's seats no
longer collapse to zero while Stripe retries the card.

Note: the Stripe webhook endpoint must have invoice.payment_failed added to
its enabled events after this deploys; it currently only sends checkout and
subscription events.
@cursor

cursor Bot commented Aug 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@richiemcilroy
richiemcilroy merged commit da0a81d into main Aug 4, 2026
21 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant