Skip to content

fix(billing): prevent & contain duplicate DevPass subscriptions#2697

Open
steebchen wants to merge 3 commits into
mainfrom
pro-plan-allowance-mismatch
Open

fix(billing): prevent & contain duplicate DevPass subscriptions#2697
steebchen wants to merge 3 commits into
mainfrom
pro-plan-allowance-mismatch

Conversation

@steebchen

@steebchen steebchen commented Jun 16, 2026

Copy link
Copy Markdown
Member

The incident

A PRO DevPass customer reported their dashboard allowance was "only $20" (vs the expected ~$237 = $79 × 3), showing "Allowance reached for this billing cycle" after spending $19.67 — plus a baffling failed $79 "Credit Topup" they never initiated.

Stripe data showed the customer's checkout produced two subscriptions and two copies of the same card:

Stripe
Code Pro — Expired first attempt, 04:04 $79 payment Cancelled/Failed ("the attached payment method has been removed")
Code Pro — Active second attempt, 04:06 $79 payment Succeeded, next invoice 14 Jul
Visa •••• 2613 ×2 same card attached twice

The $237 was granted correctly by the active subscription, the card is valid, and the subscription is healthy. The duplicate first attempt then corrupted everything. This PR fixes the duplicate at the source and contains the two ways a stale subscription leaked into billing state.

Fix 1 (root cause) — prevent duplicates being created

Setup-mode checkout creates one subscription per setup session, and the pre-checkout guard only blocks fully activated plans (dev-plans.ts:200) — so a retried checkout after a failed first attempt creates a second subscription, and each session saves the card as a fresh PaymentMethod (within-org fingerprint dedup was absent).

In finalizeDevPlanSetupSession, before creating/activating this session's subscription:

  • Cancel any stale incomplete/past_due DevPass subscriptions from prior attempts (matched on metadata; the current session is excluded via setupSessionId, keeping it safe under the finalize/webhook race).
  • Detach duplicate copies of the card by fingerprint, keeping the one in use.

Result: a customer ends up with exactly one DevPass subscription and one card.

Fix 2 (the "$20") — stale subscription froze the active plan

handleSubscriptionUpdated detected the plan type from event metadata alone:

const isDevPlan =
	metadata?.subscriptionType === "dev_plan" ||
	organization.devPlanStripeSubscriptionId === subscription.id;

~24h after checkout the abandoned first subscription flipped to incomplete_expired; its customer.subscription.updated still carried subscriptionType: dev_plan, so the dev-plan branch ran for a subscription that wasn't the org's active one → freezeDevPlanCredits pinned devPlanCreditsLimit to the $19.67 already spent. The dashboard renders that as "$20" via toFixed(0), $0.00 remaining / 100% used — matching the screenshot. handleInvoicePaymentFailed already gated on the matching id; this mirrors it for subscription.updated.

Fix 3 — subscription invoice failure mislabeled as a credit top-up

The first attempt's failed $79 fired payment_intent.payment_failed. handlePaymentIntentFailed recorded a credit_topup for every non-wallet failed intent — including subscription invoices — producing the phantom "Credit top-up failed" row. The success handler already guards this via baseAmount === undefined; the failure handler now does too (only records a top-up when baseAmount is set or a pending transactionId exists). Subscription-failure tracking (paymentFailure row + dunning email) is preserved.

Edge case in practice — failed DevPass renewals are rare (new + monthly), and this row came from the unusual "payment method has been removed" retry, not a routine decline.

Immediate customer remediation (data, not code)

Their subscription is active and card valid (an earlier "update your card" diagnosis was wrong). Unfreeze: set devPlanCreditsFrozen=false and restore devPlanCreditsLimit from devPlanCreditsLimitBeforeFreeze ($237). With Fix 2, a subscription.updated on the active sub would also auto-restore via restoreDevPlanCredits.

Tests

stripe.spec.ts (13 passing, +3 cases):

  • stale (different-id) incomplete_expired subscription update → does not freeze the active plan or touch cancel flags
  • failed subscription invoice intent → no credit_topup row, but failure tracking still runs
  • failed manual credit purchase (baseAmount set) → credit_topup failed row recorded

The Fix 1 cleanup helpers call Stripe directly; consistent with the existing finalizeDevPlanSetupSession (no Stripe-mock coverage), each Stripe call is individually guarded so a single failure can't abort activation.

Follow-up (not in this PR)

Dashboard messaging: a frozen account shows "Allowance reached… Upgrade to keep coding" rather than a payment-failure prompt; devPlanCreditsFrozen isn't exposed by /dev-plans/status.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Corrected payment failure handling to properly separate failed subscription-invoice payments from manual credit purchases.
    • Subscription failures now trigger dunning email and failure tracking without creating incorrect credit top-up transactions.
    • Prevented superseded/expired subscription update events from altering the current active subscription’s state.
  • New Features

    • Improved dev-plan subscription setup by cleaning up stale incomplete subscriptions and removing duplicate saved cards.
  • Tests

    • Expanded coverage for payment-intent failure and subscription update scenarios.

A failed PaymentIntent for a subscription invoice (Pro / DevPass /
chat plan) was recorded as a `credit_topup` transaction, producing a
phantom "Credit top-up failed via Stripe" row on the customer's
billing history even though they never initiated a credit purchase.

handlePaymentIntentSucceeded already ignores subscription invoice
intents (they lack `baseAmount` metadata), but handlePaymentIntentFailed
only skipped end-user wallet top-ups and treated every other failed
intent as a credit top-up. Gate the credit_topup transaction insert on
the same discriminator: real top-ups always set `baseAmount` (manual +
auto) or carry a pending `transactionId`; subscription invoice intents
carry neither. Subscription-failure tracking (paymentFailure row +
dunning email) and dev/chat plan credit freezes are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e8327a4c-346e-4243-8b7f-2f34f52f5089

📥 Commits

Reviewing files that changed from the base of the PR and between 6ad529e and 32fc007.

📒 Files selected for processing (1)
  • apps/api/src/stripe.ts

Walkthrough

Dev-plan setup is hardened with stale-subscription cancellation and duplicate-card cleanup before charging. Two Stripe webhook handlers fix critical bugs: handlePaymentIntentFailed now records credit_topup transaction rows only for actual credit top-ups and is exported, while handleSubscriptionUpdated gains early-return guards to skip expiry/freeze/cancel logic when the webhook subscription id is stale. Tests cover all three changes.

Changes

Stripe Webhook Handler Fixes and Setup Cleanup

Layer / File(s) Summary
Dev-plan setup: cancel stale subscriptions and detach duplicate cards
apps/api/src/stripe.ts
finalizeDevPlanSetupSession adds helpers to cancel customer dev-plan subscriptions from earlier setupSessionIds and detach duplicate PaymentMethods sharing the same card fingerprint (keeping the one being used). Cleanups run after setting the default payment method and before subscription creation, logging failures but not blocking checkout.
Export and conditional credit_topup gating in handlePaymentIntentFailed
apps/api/src/stripe.ts
handlePaymentIntentFailed is exported and its transaction-recording logic is gated on metadata.baseAmount or metadata.transactionId. Credit top-up failures update an existing pending row to failed or insert a new credit_topup failed row; non-top-up failures (e.g., subscription invoice) skip transaction mutation entirely but still record the failure for admin tracking. Fallback description format is updated.
Stale subscription guard in handleSubscriptionUpdated
apps/api/src/stripe.ts
For dev and chat plans, when the webhook subscription id does not match the organization's stored active subscription id, handleSubscriptionUpdated logs and returns early, preventing expiry, cancel, freeze, or restoration logic from running on superseded subscriptions.
Tests for webhook handler fixes and setup cleanup
apps/api/src/stripe.spec.ts
Imports handlePaymentIntentFailed; extends makeUpdatedEvent with a subscriptionId override; adds a test asserting stale incomplete_expired events do not freeze credits or alter dev-plan state; adds makeFailedPaymentIntentEvent helper and a two-case suite verifying no credit_topup row for invoice failures (but paymentFailureCount and dunning email fire) and a failed credit_topup row with correct creditAmount for manual credit failures.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • theopenco/llmgateway#2424: Both PRs modify finalizeDevPlanSetupSession in apps/api/src/stripe.ts; main PR adds stale-subscription cancellation and duplicate-card detaching, which directly overlaps with the duplicate-card-before-charging changes.
  • theopenco/llmgateway#2113: Both PRs update payment-intent handlers in apps/api/src/stripe.ts to gate credit-topup transaction logic on metadata.baseAmount, with this PR fixing the failed path.
  • theopenco/llmgateway#2137: Both PRs modify dev-plan credit-freeze and subscription-status logic in handleSubscriptionUpdated around incomplete/incomplete_expired states, with this PR adding a stale-subscription early return.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: preventing and containing duplicate DevPass subscriptions, which is the core fix and the primary objective of this PR.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 pro-plan-allowance-mismatch

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 and usage tips.

A superseded DevPass/chat subscription — e.g. an abandoned first
checkout attempt whose payment never completed and that Stripe later
marks `incomplete_expired` — still carries `subscriptionType` metadata.
handleSubscriptionUpdated detected the plan type from that metadata
alone, so the stale subscription's expiry ran the dev-plan branch and
froze the *active* plan's credits (pinning the limit to current usage),
silently throttling a healthy subscriber to their already-spent amount.

Gate subscription.updated handling on the event's subscription being the
org's current dev/chat plan subscription, mirroring the id check
handleInvoicePaymentFailed already performs. Stale events for a
different subscription id are ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen steebchen changed the title fix(billing): don't log subscription failures as topups fix(billing): stop stale/dup subscriptions corrupting DevPass credits Jun 16, 2026
Setup-mode checkout creates one subscription per setup session, so a
retried checkout after a failed first attempt left the customer with two
DevPass subscriptions (a dangling incomplete one plus the active one) and
two PaymentMethod objects for the same card. The dangling subscription
later expired and corrupted the active plan's credit state.

When finalizing a session, before creating/activating its subscription:
cancel any stale incomplete/past_due DevPass subscriptions from prior
attempts (matched on metadata, excluding the current session so it stays
safe under the finalize/webhook race), and detach duplicate copies of the
card by fingerprint, keeping the one being used. Combined with the
id-gating in handleSubscriptionUpdated, a customer ends up with exactly
one DevPass subscription and one card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen steebchen changed the title fix(billing): stop stale/dup subscriptions corrupting DevPass credits fix(billing): prevent & contain duplicate DevPass subscriptions Jun 16, 2026
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