-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[PM 29595]Implement : User that Upgraded from Premium Reverts an Organization Upgrade During the Trial Period #6849
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6849 +/- ##
==========================================
+ Coverage 56.00% 56.06% +0.06%
==========================================
Files 1966 1966
Lines 86875 87047 +172
Branches 7737 7755 +18
==========================================
+ Hits 48650 48805 +155
- Misses 36426 36437 +11
- Partials 1799 1805 +6 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…mium-reverts-an-organization-upgrade-during-the-trial-period
…mium-reverts-an-organization-upgrade-during-the-trial-period
|
New Issues (2)Checkmarx found the following issues in this Pull Request
|
amorask-bitwarden
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work here, just a few questions and thoughts on the logic.
| private async Task<bool> TryRevertPremiumUpgradeAsync(Subscription subscription, Organization organization) | ||
| { | ||
| // Extract all metadata once | ||
| var metadata = subscription.Metadata ?? new Dictionary<string, string>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
subscription.metadata is not a nullable property: https://docs.stripe.com/api/subscriptions/object?api-version=2025-08-27.basil&lang=cli#subscription_object-metadata
|
|
||
| // STEP 2: Get the current Premium plan details from pricing client | ||
| var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); | ||
| if (premiumPlan == null || !premiumPlan.Available) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pricingClient.GetAvailablePremiumPlan throws a NotFoundException. It does not return null which is why the result isn't nullable.
| updatedMetadata.Remove(MetadataKeys.OrganizationId); | ||
|
|
||
| // STEP 6: Update Stripe subscription | ||
| var updateOptions = new SubscriptionUpdateOptions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the periodEnd variable for? Is that supposed to go somewhere in here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ Same question.
| { | ||
| // STEP 1: Fastest check first - check if subscription has premium upgrade metadata | ||
| // This avoids expensive deserialization for subscriptions without this metadata | ||
| if (subscription.Metadata == null || |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
subscription.metadata is not a nullable property.
…mium-reverts-an-organization-upgrade-during-the-trial-period
| if (cancelImmediately) | ||
| { | ||
| if (subscription.Metadata != null && subscription.Metadata.ContainsKey("organizationId")) | ||
| if (subscription.Metadata.ContainsKey("organizationId")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⛏️ Let's use StripeConstants.MetadataKeys here.
| // rather than "reversion failed" - the cancellation should proceed normally. | ||
|
|
||
| // Check if metadata exists | ||
| if (metadata == null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⛏️ Mentioned this a couple of times - a Subscription's metadata is not a nullable property in Stripe's API so this check is not necessary.
| // Check if this is a Premium-to-Organization upgrade that can be reverted | ||
| if (subscriber is Organization organization) | ||
| { | ||
| var canRevert = await TryRevertPremiumUpgradeAsync(subscription, organization); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❌ This is confusing. Your method that actually does the reversion of the premium upgrade returns a boolean that indicates whether the organization can revert in the first place? I think these should be 2 methods: 1 to check of an organization can revert and another to do the reversion.
| updatedMetadata.Remove(MetadataKeys.OrganizationId); | ||
|
|
||
| // STEP 6: Update Stripe subscription | ||
| var updateOptions = new SubscriptionUpdateOptions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ Same question.
| var premiumPlans = await _pricingClient.ListPremiumPlans(); | ||
| var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet(); | ||
| return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id)); | ||
| if (premiumPlans == null || !premiumPlans.Any()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The response from ListPremiumPlans is also not nullable.
| } | ||
|
|
||
| var premiumPriceIds = premiumPlans | ||
| .Where(p => p.Seat != null && p.Storage != null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Neither of these are nullable.
| .SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }) | ||
| .ToHashSet(); | ||
|
|
||
| return subscription.Items.Any(i => i.Price != null && premiumPriceIds.Contains(i.Price.Id)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Price is not nullable.
| // Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the | ||
| // previous and/or current subscriptions. | ||
| var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans()) | ||
| var plans = await _pricingClient.ListPlans(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we making changes to the RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync as part of this work?
|
|
||
| // STEP 5: Check if subscription still has OrganizationId (race condition check) | ||
| // If reversion already happened, OrganizationId would be removed and we should skip cleanup | ||
| if (!subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you explain this logic here?
…mium-reverts-an-organization-upgrade-during-the-trial-period
…mium-reverts-an-organization-upgrade-during-the-trial-period
amorask-bitwarden
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a couple small things then we're good to go.
| /// <param name="subscription">The Stripe subscription to check</param> | ||
| /// <param name="organization">The organization attempting to cancel</param> | ||
| /// <returns>True if the reversion is possible, false otherwise</returns> | ||
| private Task<bool> CanRevertPremiumUpgradeAsync(Subscription subscription, Organization organization) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ Why is this returning a Task? Also, can we rename it to something more aligned with the process? Something along the lines of IsRevertingPremiumUpgrade?
| // Check if this is a Premium-to-Organization upgrade that can be reverted | ||
| if (subscriber is Organization organization) | ||
| { | ||
| var canRevert = await CanRevertPremiumUpgradeAsync(subscription, organization); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⛏️ canRevert is unnecessary.
if (IsRevertingPremiumUpgrade(subscription, organization))
{
await RevertPremiumUpgradeAsync(subscription, organization);
return;
}
amorask-bitwarden
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work - thank you for handling the feedback.


🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-29595
📔 Objective
Feature: Allow users to revert Premium-to-Organization upgrades during trial period
Summary
Enables users who upgraded from Premium to Organization to revert the upgrade during the trial period. If they cancel during trial, the subscription reverts to their original Premium plan instead of being canceled.
Key Changes
📸 Screenshots
⏰ Reminders before review
🦮 Reviewer guidelines
:+1:) or similar for great changes:memo:) or ℹ️ (:information_source:) for notes or general info:question:) for questions:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion:art:) for suggestions / improvements:x:) or:warning:) for more significant problems or concerns needing attention:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt:pick:) for minor or nitpick changes