Skip to content

Fix parallel Stripe subscriptions and improve subscription sync - #1463

Open
drusepth wants to merge 2 commits into
tailwind-redesignfrom
claude/parallel-stripe-subscriptions-p0cs3l
Open

Fix parallel Stripe subscriptions and improve subscription sync#1463
drusepth wants to merge 2 commits into
tailwind-redesignfrom
claude/parallel-stripe-subscriptions-p0cs3l

Conversation

@drusepth

@drusepth drusepth commented Aug 8, 2026

Copy link
Copy Markdown
Member

Fixes #

Changes proposed:

  • Refactor subscription synchronization logic: Extract complex Stripe subscription management into a dedicated sync_stripe_subscriptions_to_plan method that handles creating, modifying, and canceling subscriptions. This replaces the inline logic that was creating duplicate subscriptions.

  • Prevent duplicate subscriptions: Implement proper handling of parallel subscriptions by:

    • Listing all billable subscriptions (active, trialing, past_due, unpaid, incomplete) instead of relying on the deprecated customer.subscriptions field
    • Canceling all parallel subscriptions with proration when switching plans
    • Preferring to keep existing subscriptions on the requested plan to avoid interrupting billing periods
    • Adding payment_behavior: 'error_if_incomplete' to prevent incomplete subscriptions from being left behind on card failures
  • Add helper methods for subscription management:

    • billable_stripe_subscriptions(): Fetches all billable subscriptions for a customer
    • prioritize_subscriptions_to_keep(): Sorts subscriptions by health (status) and age (creation date)
    • subscription_price_ids(): Extracts price IDs from subscription items
    • subscription_period_end(): Gets period end from subscription or items (handles API version differences)
  • Add comprehensive test coverage: New test file with 9 test cases covering subscription filtering, creation, modification, cancellation, and edge cases like incomplete duplicates and scheduled cancellations.

  • Add Stripe audit rake task: New stripe:audit_parallel_subscriptions task to find and optionally cancel duplicate subscriptions across all customers, with dry-run mode by default.

  • Update controllers to use new methods:

    • subscriptions_controller: Use billable_stripe_subscriptions() instead of deprecated customer.subscriptions
    • users_controller: Cancel all billable subscriptions on account deletion instead of downgrading to starter
    • Add row-level locking in process_plan_change() to prevent race conditions on concurrent plan changes
  • Update views: Use new helper methods to safely access subscription data and period end times.

  • Fix plan change links: Change starter plan link from GET to POST with method: :post to properly serialize plan changes and prevent accidental duplicate requests.

@indentlabs/contributors

https://claude.ai/code/session_01GAr8oQfJnvJnUXpH2ejHRF

claude added 2 commits August 8, 2026 02:29
Users have been accumulating multiple simultaneous Stripe subscriptions
(e.g. 3 starter + 1 premium), double-charging them. Root cause: current
Stripe API versions no longer include `subscriptions` on retrieved
Customer objects, so every `stripe_customer.subscriptions&.data || []`
read silently returned []. Every plan change therefore looked like a
first-time signup and created a brand-new subscription, while the old
one was never modified or canceled Stripe-side (cancellation only ever
touched our local records). Repeated clicks on a slow page multiplied
the effect. The same dead read also silently skipped subscription
cancellation when deleting a payment method or an account, and
`Stripe::Subscription.modify` no longer exists in stripe-ruby 15, so
the modify path would have crashed even if a subscription had been
found.

Fixes:
- SubscriptionService now lists subscriptions via Stripe::Subscription
  .list and enforces a single-subscription invariant on every plan
  change: keep one subscription (preferring the requested price, then
  healthy status, then oldest), switch its price in place, and cancel
  any parallel duplicates with proration so unused time is credited.
  Existing double-subscribed users are healed the next time they touch
  their plan.
- Plan changes are serialized per user with a row lock, so concurrent
  double-clicks can't race each other into duplicate subscriptions;
  re-choosing the current plan is now a no-op (and un-schedules a
  pending cancellation instead of stacking a new subscription).
- New subscriptions are created with payment_behavior:
  error_if_incomplete so a declined card raises Stripe::CardError
  (routing to the existing failed-card flow) instead of leaving an
  incomplete subscription behind.
- delete_payment_method now cancels every paid subscription at period
  end (the old code no-opped on the dead read and used a removed API);
  delete_my_account now cancels all billable subscriptions instead of
  re-pricing just the first one.
- Plan-change links now submit via POST with a disable-on-click guard;
  the GET route remains for legacy links and is safe now that changes
  are idempotent.
- The payment page's "already paid until" notice works again and reads
  period ends from subscription items, where newer API versions moved
  them.
- New rake task stripe:audit_parallel_subscriptions reports every
  customer with parallel subscriptions (dry run by default; APPLY=1
  cancels duplicates with proration) to clean up existing damage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAr8oQfJnvJnUXpH2ejHRF
Auditing the rest of the billing code turned up three more places built on
the same dead `stripe_customer.subscriptions&.data || []` read, plus a
failure mode the first fix made more likely.

- data_integrity:subscription_synced_with_stripe was a mass-downgrade
  landmine. It decides whether to downgrade a premium user based on that
  always-empty read, so on current Stripe API versions it would conclude
  that EVERY premium user had unsubscribed and downgrade the entire paying
  user base (emailing each one). It now lists subscriptions properly and
  checks all of them, refuses to downgrade more than 20% of premium users
  in a single run (a wrong-looking mass downgrade almost certainly means we
  are misreading Stripe rather than that everyone churned), and supports
  DRY_RUN=1.

- Admin "unsubscribe" only ended subscriptions in our database and never
  cancelled them on Stripe, so unsubscribed users kept getting billed. It
  now cancels on Stripe first. cancel_all_existing_subscriptions is
  documented as local-only, with cancel_stripe_subscriptions! added for
  callers that are cancelling outright rather than switching plans.

- New-user signup created its starter subscription with a hand-rolled
  Stripe::Subscription.create that did not check for existing
  subscriptions. It now goes through the same one-subscription invariant as
  every other path.

- A declined card used to commit the local downgrade. process_plan_change
  cancels the old plan locally before adding the new one, and
  add_subscription swallowed Stripe::CardError and returned, so the
  transaction committed and the user lost the plan and bandwidth they had
  already paid for. Making creation raise on a declined card (error_if_
  incomplete) made this reachable. CardError now propagates out of the
  transaction, which is opened with requires_new so it rolls back even if
  nested, and the controller converts it to :failed_card afterwards.

Tests: 12 covering the single-subscription invariant against stubbed
Stripe HTTP (cancel duplicates, modify-not-create, create-when-none, no-op
on repeat, active-beats-incomplete, un-cancel, cancel-all, period-end
fallback) plus an integration test asserting a declined card leaves the
user's plan, bandwidth and local subscriptions untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAr8oQfJnvJnUXpH2ejHRF
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.

2 participants