Skip to content

fix(billing): end free trials on Hobby unless the org explicitly buys a plan - #9785

Draft
Dhruwang wants to merge 1 commit into
mainfrom
dhruwang/eng-4035-require-explicit-purchase-for-paid-trial-conversion-default
Draft

Dhruwang wants to merge 1 commit into
mainfrom
dhruwang/eng-4035-require-explicit-purchase-for-paid-trial-conversion-default

Conversation

@Dhruwang

Copy link
Copy Markdown
Member

Ref ENG-4035

What & why

Was: A free Pro trial with any saved card — on the subscription or only the customer — was billed for Pro when the trial ended, so saving billing details silently bought the plan.

Now: Every new free trial ends on the free Hobby plan with no invoice, card or no card. Only an explicit upgrade whose payment succeeds keeps a paid plan.

  • Stripe enforces it, not our app: the trial cancels at the exact second it ends (cancel_at === trial_end, no proration).
  • The upgrade clears cancel_at in the same error_if_incomplete update, so a declined or 3DS charge rolls back with the fallback intact.
  • Trial copy now says the trial ends on Hobby without a charge; the trial-ending modal button says "Upgrade".

Where to look

Breaking changes

  • This PR contains breaking changes

None — Cloud-only billing internals; no API, SDK, env var or self-hosting change.

Migrations & env

  • none. Existing Stripe trials are not retrofitted by this deploy; the reviewed migration is a follow-up on ENG-4035.

How this was tested

Rerun: cd apps/web && npx vitest run modules/ee/billing/lib/free-trial-end.test.ts modules/ee/billing/lib/organization-billing.test.ts

Coverage

Behaviour How Outcome
Trial expiry bills nothing, in 5 card states manual Stripe test clocks: 0 invoices each; today's config billed $89 (table below)
Trial is created with cancel_at === trial_end unit (red on main) "cancels the subscription at the exact trial end so expiry never bills Pro"
Upgrade clears cancel_at in the paid update; decline keeps it unit (red on main) "bills the FULL price…", "a declined conversion charge propagates…"; test clocks confirm
Trial → Hobby clears cancel_at unit (red on main) Three Trial → Hobby tests expect cancel_at: ""
Trial end and cancel share one timestamp, DST-safe unit (mutation) free-trial-end.test.ts; mutate free-trial-end.ts:28 (cancel_at) to trialEndUnix + 1
Trial copy promises Hobby, no charge; CTA is Upgrade manual Pro trial, 2 days left, fixture org — screenshots below
Stripe test-clock results (test mode, API 2026-02-25.clover)

7-day Pro trial ($89/mo + metered responses), clock advanced to trial end + 1 day.

Scenario Invoices End state
Today's config, card at signup $0 create, $89 at trial end (paid) active Pro — the bug
New config, no card $0 only canceled → Hobby
New config, card at signup + 500 metered responses $0 only canceled
New config, card added mid-trial $0 only canceled
New config, card that fails on charge $0 only canceled
New config, card only on the customer $0 only canceled
Upgrade mid-trial, good card $0 + $89 paid active; survives the original trial end
Upgrade, declined card $0 only; card_declined trial intact → canceled at trial end
Upgrade, 3DS card $0 only; subscription_payment_intent_requires_action trial intact → canceled at trial end
Upgrade after the trial already expired $0 only; invalid_canceled_subscription_fields canceled, nothing billed
Screenshots — Pro trial with 2 days left
Before After
Trial-ending modal: "you will lose access" and an "Add payment method" button Trial-ending modal: "you move to the free Hobby plan, you won't be charged" and an "Upgrade" button
Billing page: "Your trial cancels automatically on Oct 24, 2026" Billing page: "Your trial ends on Oct 24, 2026. You'll move to the free Hobby plan and won't be charged unless you upgrade"

Open gaps

  • Existing trials and Dashboard-made Scale/demo trials keep their old settings until the follow-up migration runs.
  • The webhook-driven Hobby fallback after expiry ran only in Stripe, not end-to-end against a local app.

Risks

  • The modal's PostHog event now sends cta: "upgrade"; dashboards filtering add_payment_method need updating.
  • Two simultaneous trial starts: the second now fails on the idempotency key instead of returning the first.
  • Sending cancel_at_period_end: false clears cancel_at; scheduling on a trialing subscription now throws to prevent that.

Note

AI model used — claude-opus-5-5, reasoning effort unknown.

… a plan [ENG-4035]

A Pro trial only cancelled at expiry when no card was on file. Any saved card —
on the subscription or just the customer — made Stripe invoice Pro at trial_end,
so saving billing details silently bought the plan.

Free trials now cancel at the exact second the trial ends (cancel_at ===
trial_end, no proration), so Stripe never invoices them and the deletion
webhook moves the org to Hobby. An explicit upgrade clears cancel_at in the
same error_if_incomplete update that ends the trial, so a declined or 3DS
charge rolls back with the fallback intact. Trial -> Hobby clears it too, and
scheduling a plan change on a trialing subscription now throws. Trial copy says
the trial ends on Hobby without a charge; the ending modal CTA is now Upgrade.

Entire-Checkpoint: 01M5KVSCHGMS3RRQAP6J89ENCX
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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