[PM-33289] Stop 500-retry loop on incomplete_expired subs#7525
Merged
amorask-bitwarden merged 2 commits intomainfrom May 7, 2026
Merged
[PM-33289] Stop 500-retry loop on incomplete_expired subs#7525amorask-bitwarden merged 2 commits intomainfrom
amorask-bitwarden merged 2 commits intomainfrom
Conversation
Contributor
|
Great job! No new security vulnerabilities introduced in this pull request |
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #7525 +/- ##
==========================================
+ Coverage 59.13% 59.21% +0.07%
==========================================
Files 2077 2077
Lines 91848 91898 +50
Branches 8175 8179 +4
==========================================
+ Hits 54315 54414 +99
+ Misses 35601 35540 -61
- Partials 1932 1944 +12 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
cyprain-okeke
approved these changes
Apr 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-33289
📔 Objective
Fixes a webhook processing bug where personal Premium subscriptions were being silently disabled and staying disabled despite Support's manual re-enables. Confirmed in the field against five affected customers, with Stripe delivery logs showing up to five automatic disables per customer over three days.
Root cause.
SubscriptionUpdatedHandler.HandleAsynccombined theSubscriptionWentUnpaidandSubscriptionWentIncompleteExpiredbranches, callingSetSubscriptionToCancelAsyncfor both. That method invokesstripeAdapter.UpdateSubscriptionAsync(..., CancelAt = now + 7d), which Stripe rejects on a subscription that is alreadyincomplete_expired(a terminal state). The resultingStripeExceptionpropagates out of the webhook controller as HTTP 500; Stripe retries 5xx responses for up to 72 hours, and every retry re-executesDisablePremiumAsync(userId)on the customer — silently flippinguser.Premium = falseafter Support had flipped it back true.Primary fix. Split the branches so the
IncompleteExpiredpath only disables the subscriber and does not attempt a Stripe API update on the already-terminal subscription. VerifiedPriceIncreaseScheduler.Releaseis a no-op on terminal subs (it filters forActiveschedules only), so skipping the whole call is safe. Inverted three existing tests that were encoding the buggy behavior (assertingUpdateSubscriptionAsyncwas called on IncompleteExpired) to now assertDidNotReceive().Scope-expansion fix. While reviewing the handler module, found a latent bug in
PaymentSucceededHandlerandPaymentFailedHandler: both short-circuited on hardcoded Premium price ID constants ("premium-annually"/"premium-annually-app") that did not match the currentpremium-annually-2026Stripe price. Today the bug was masked byCreatePremiumCloudHostedSubscriptionCommandsettinguser.Premium = truesynchronously at signup — but any flow that depends on the webhook to enable Premium (e.g., a customer paying a stuck open invoice via the hosted-invoice page) was silently failing. Replaced the hardcoded constants with a dynamicIPricingClient.ListPremiumPlans()lookup against the Password Manager seat price (aligning withUpcomingInvoiceHandler's convention — storage is an add-on, not identity). Added try/catch around the pricing call, empty-list guards with logged errors, and null-safe plan access; on pricing-service uncertainty both handlers fall back to "not Premium" so we preserve the default behavior (don't enable Premium we can't verify; don't apply the Premium-only early-stop of pay retries we can't confirm applies). InjectedILoggerintoPaymentFailedHandler(previously had none). Added seven new tests: four forPaymentSucceededHandler(happy path, non-Premium sub, pricing throws, empty plan list) and three forPaymentFailedHandler(happy path, non-Premium sub still attempts, pricing throws falls back to default retries).