Skip to content

fix: paying customers see "Paid" and can't be double-charged#2397

Merged
vipulnsward merged 3 commits into
developfrom
fix/paid-plan-display-and-double-charge
Jul 23, 2026
Merged

fix: paying customers see "Paid" and can't be double-charged#2397
vipulnsward merged 3 commits into
developfrom
fix/paid-plan-display-and-double-charge

Conversation

@vipulnsward

@vipulnsward vipulnsward commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Part of the conversion-funnel audit (see plans/ 018-023). First in the priority order — protects your earliest paying customers.

Problems fixed

1. A customer who upgrades during their trial was still shown "Pro Trial".
Company#current_plan_label returned "pro_trial" before checking "paid", current_subscription_status returned "trialing" before the real Stripe status, and apply_stripe_subscription! never cleared the trial's end date. A paying customer was told they were still on a free trial (billing page + subscription API), until trial_ends_at passed.

Fix: prefer the paid plan and the real Stripe status over an active trial, and null trial_ends_at when a paid subscription is applied so trial predicates stop firing for a paying company.

2. No already-paid guard on checkout → double charge.
A paid workspace that reached POST /subscription/checkout again (the billing UI still showed the Upgrade button during the post-checkout webhook race — fixed separately in plan 020 — and the raw API / agent path is always reachable) created a second active Stripe subscription on the same customer and was billed twice.

Fix: early-return 422 already_subscribed when plan_tier == "paid" and point the customer at the billing portal.

Adversarial-review catch (fixed in this PR)

The first cut nulled trial_started_at too. Because trial_available? keys on trial_started_at.blank?, that would have let a subscribe → cancel cycle grant a fresh 14-day trial every time (unbounded free-tier abuse). Corrected to null only trial_ends_at and keep trial_started_at (one trial per company), with a regression spec for the subscribe→cancel cycle. Re-review confirmed the asymmetric (present-start / nil-end) state is nil-guarded everywhere and TrialEmailsJob filters it out before any dereference.

Verification

  • 95 specs green across spec/models/company_spec.rb, spec/requests/api/v1/subscriptions, spec/jobs/trial_emails_job_spec.rb, spec/services/subscriptions, spec/mailers/subscription_mailer_spec.rb, including new examples for the paid-over-trial precedence, the downgrade-preserves-timestamps case, the subscribe→cancel re-trial guard, and the already-paid checkout 422 (asserts Stripe::Checkout::Session.create is not called).
  • Behavior confirmed via rails runner: a paid mid-trial company reads current_plan_label == "paid" / status active (was pro_trial/trialing); a genuine trial still reads pro_trial/trialing.
  • RuboCop clean. Dual-model adversarial review: CONFIRMED clean.

Follow-ups (planned, not in this PR)

plans/README.md has the full funnel audit: 020 (post-checkout webhook-race polling), 019 (Stripe payment-link fulfillment), 021 (in-app trial countdown banner), 023 (trial-expired email), 022 (tease Reports). Plus three product decisions flagged for you: Analytics ungated while Reports is paywalled, per-seat billing never reconciled to Stripe, and the self-hosted trial dead-ending at day 14.

Summary by CodeRabbit

  • New Features

    • Added protection to block subscription checkout when a workspace already has an active paid subscription.
    • Users are shown an error with guidance to use Manage billing for changes.
  • Bug Fixes

    • Paid subscription status now takes precedence over active trials.
    • Trial end dates are cleared when a subscription becomes paid.
    • Cancel/downgrade flows no longer incorrectly grant an additional trial; existing trial timestamps are preserved.
  • Tests

    • Added/expanded request and model specs to cover the new checkout rule and trial/subscription precedence behavior.

A company that upgrades during its 14-day trial kept being labelled
Pro Trial / trialing because current_plan_label checked trial_active?
before plan_tier, and apply_stripe_subscription! never cleared the
trial timestamps. A paying customer was told they were still on a free
trial. Prefer the paid plan and Stripe status over an active trial, and
clear trial_started_at/trial_ends_at when a paid subscription is applied.

Guard the checkout action against an already-paid workspace: a second
checkout attached a second active Stripe subscription to the same
customer and double-billed. Return 422 already_subscribed and point the
customer at the billing portal instead.
Adversarial review: nulling trial_started_at when applying a paid
subscription erased the only record of a used trial. trial_available?
keys on trial_started_at.blank?, so a subscribe then cancel cycle would
make the workspace eligible for a fresh 14-day trial every time. Null
only trial_ends_at (stops a leftover in-flight trial from re-granting
access after cancel) and keep trial_started_at (one trial per company).
Add a regression spec for the subscribe-cancel cycle.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86f7791d-c155-4129-bf94-92932df9cdbd

📥 Commits

Reviewing files that changed from the base of the PR and between b7f825f and 512e01b.

📒 Files selected for processing (1)
  • spec/requests/api/v1/subscriptions/checkout_spec.rb
🚧 Files skipped from review as they are similar to previous changes (1)
  • spec/requests/api/v1/subscriptions/checkout_spec.rb

📝 Walkthrough

Walkthrough

Changes

Subscription state and checkout

Layer / File(s) Summary
Subscription status and trial transitions
app/models/company.rb, spec/models/company_spec.rb
Paid status takes precedence over active trials; paid subscription application clears trial_ends_at, and cancellation scenarios preserve the tested trial state.
Paid checkout rejection
app/controllers/api/v1/subscriptions_controller.rb, config/locales/en.yml, spec/requests/api/v1/subscriptions/checkout_spec.rb
Paid companies receive a translated 422 response and do not create a Stripe checkout session.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • saeloun/miru-web#2212: Updates adjacent Stripe subscription synchronization and billing-state handling in Company.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: paid customers are shown as paid and duplicate checkout charges are prevented.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/paid-plan-display-and-double-charge

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/controllers/api/v1/subscriptions_controller.rb`:
- Around line 13-16: Make the checkout creation flow in SubscriptionsController
idempotent before creating a Stripe Checkout Session: under a current_company
lock, persist and reuse an in-progress session, or apply a company-scoped Stripe
idempotency key, while preserving the existing paid-plan guard. Add a regression
spec that issues concurrent free-company requests and verifies only one checkout
session is created and reused.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 77608eb3-eef3-4741-923c-efbe9dfe7e6a

📥 Commits

Reviewing files that changed from the base of the PR and between e8a93e8 and b7f825f.

📒 Files selected for processing (5)
  • app/controllers/api/v1/subscriptions_controller.rb
  • app/models/company.rb
  • config/locales/en.yml
  • spec/models/company_spec.rb
  • spec/requests/api/v1/subscriptions/checkout_spec.rb

Comment on lines +13 to +16
if current_company.plan_tier == "paid"
render json: { errors: I18n.t("subscriptions.already_subscribed") }, status: 422
return
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make checkout creation idempotent, not only post-subscription guarded.

Two concurrent requests from a free workspace both pass this check and can each create a Stripe Checkout Session before plan_tier becomes paid. Persist/reuse an in-progress checkout session under a company lock or use a scoped Stripe idempotency key, then add a concurrent-request regression spec.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/controllers/api/v1/subscriptions_controller.rb` around lines 13 - 16,
Make the checkout creation flow in SubscriptionsController idempotent before
creating a Stripe Checkout Session: under a current_company lock, persist and
reuse an in-progress session, or apply a company-scoped Stripe idempotency key,
while preserving the existing paid-plan guard. Add a regression spec that issues
concurrent free-company requests and verifies only one checkout session is
created and reused.

@vipulnsward
vipulnsward merged commit 971957a into develop Jul 23, 2026
13 checks passed
@vipulnsward
vipulnsward deleted the fix/paid-plan-display-and-double-charge branch July 23, 2026 16:44
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