-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[PM-29598] Create Subscription Upgrade Endpoint #6787
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
Merged
cyprain-okeke
merged 23 commits into
main
from
billing/pm-29598/create-subscription-upgrade-endpoint
Jan 9, 2026
+933
โ2
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
35d8812
Add the ticket implementation
cyprain-okeke 8276423
Add the unit test
cyprain-okeke ffdef93
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke dc93149
Fix the lint and test issues
cyprain-okeke bca4274
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke 64fa791
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke 2c6c2fe
resolve pr comments
cyprain-okeke 383530d
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke 79a8be0
Fix the error on the test file
cyprain-okeke d8ebcc9
Review suggestion and fixes
cyprain-okeke 00f4ad6
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke 025e3e0
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke 9ba9ef1
resolve the api access comments
cyprain-okeke 0687f31
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke a64e0a0
Gte the key from the client
cyprain-okeke 9b54c2e
Add the gateway type as stripe
cyprain-okeke 244ca57
Address the legacy plans issues
cyprain-okeke cbdec08
Resolve the misunderstanding
cyprain-okeke 823db8c
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke 5299129
Add additional storage that we will need if they revert
cyprain-okeke f728816
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke 9b3a68f
Add the previous premium UserId
cyprain-okeke d2f197a
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโฆ
cyprain-okeke File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
37 changes: 37 additions & 0 deletions
37
src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| ๏ปฟusing System.ComponentModel.DataAnnotations; | ||
| using System.Text.Json.Serialization; | ||
| using Bit.Core.Billing.Enums; | ||
|
|
||
| namespace Bit.Api.Billing.Models.Requests.Premium; | ||
|
|
||
| public class UpgradePremiumToOrganizationRequest | ||
| { | ||
| [Required] | ||
| public string OrganizationName { get; set; } = null!; | ||
|
|
||
| [Required] | ||
| public string Key { get; set; } = null!; | ||
|
|
||
| [Required] | ||
| [JsonConverter(typeof(JsonStringEnumConverter))] | ||
| public ProductTierType Tier { get; set; } | ||
|
|
||
| [Required] | ||
| [JsonConverter(typeof(JsonStringEnumConverter))] | ||
| public PlanCadenceType Cadence { get; set; } | ||
|
|
||
| private PlanType PlanType => | ||
| Tier switch | ||
| { | ||
| ProductTierType.Families => PlanType.FamiliesAnnually, | ||
| ProductTierType.Teams => Cadence == PlanCadenceType.Monthly | ||
| ? PlanType.TeamsMonthly | ||
| : PlanType.TeamsAnnually, | ||
| ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly | ||
| ? PlanType.EnterpriseMonthly | ||
| : PlanType.EnterpriseAnnually, | ||
| _ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.") | ||
| }; | ||
|
|
||
| public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType); | ||
| } | ||
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
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
228 changes: 228 additions & 0 deletions
228
src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| ๏ปฟusing Bit.Core.AdminConsole.Entities; | ||
| using Bit.Core.Billing.Commands; | ||
| using Bit.Core.Billing.Constants; | ||
| using Bit.Core.Billing.Enums; | ||
| using Bit.Core.Billing.Extensions; | ||
| using Bit.Core.Billing.Pricing; | ||
| using Bit.Core.Billing.Services; | ||
| using Bit.Core.Entities; | ||
| using Bit.Core.Enums; | ||
| using Bit.Core.Repositories; | ||
| using Bit.Core.Services; | ||
| using Bit.Core.Utilities; | ||
| using Microsoft.Extensions.Logging; | ||
| using OneOf.Types; | ||
| using Stripe; | ||
|
|
||
| namespace Bit.Core.Billing.Premium.Commands; | ||
| /// <summary> | ||
| /// Upgrades a user's Premium subscription to an Organization plan by creating a new Organization | ||
| /// and transferring the subscription from the User to the Organization. | ||
| /// </summary> | ||
| public interface IUpgradePremiumToOrganizationCommand | ||
| { | ||
| /// <summary> | ||
| /// Upgrades a Premium subscription to an Organization subscription. | ||
| /// </summary> | ||
| /// <param name="user">The user with an active Premium subscription to upgrade.</param> | ||
| /// <param name="organizationName">The name for the new organization.</param> | ||
| /// <param name="key">The encrypted organization key for the owner.</param> | ||
| /// <param name="targetPlanType">The target organization plan type to upgrade to.</param> | ||
| /// <returns>A billing command result indicating success or failure with appropriate error details.</returns> | ||
| Task<BillingCommandResult<None>> Run( | ||
| User user, | ||
| string organizationName, | ||
| string key, | ||
| PlanType targetPlanType); | ||
| } | ||
|
|
||
| public class UpgradePremiumToOrganizationCommand( | ||
| ILogger<UpgradePremiumToOrganizationCommand> logger, | ||
| IPricingClient pricingClient, | ||
| IStripeAdapter stripeAdapter, | ||
| IUserService userService, | ||
| IOrganizationRepository organizationRepository, | ||
| IOrganizationUserRepository organizationUserRepository, | ||
| IOrganizationApiKeyRepository organizationApiKeyRepository, | ||
| IApplicationCacheService applicationCacheService) | ||
| : BaseBillingCommand<UpgradePremiumToOrganizationCommand>(logger), IUpgradePremiumToOrganizationCommand | ||
| { | ||
| public Task<BillingCommandResult<None>> Run( | ||
| User user, | ||
| string organizationName, | ||
| string key, | ||
| PlanType targetPlanType) => HandleAsync<None>(async () => | ||
| { | ||
| // Validate that the user has an active Premium subscription | ||
| if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) | ||
| { | ||
| return new BadRequest("User does not have an active Premium subscription."); | ||
| } | ||
|
|
||
| // Hardcode seats to 1 for upgrade flow | ||
| const int seats = 1; | ||
|
|
||
| // Fetch the current Premium subscription from Stripe | ||
| var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); | ||
|
|
||
| // Fetch all premium plans to find which specific plan the user is on | ||
| var premiumPlans = await pricingClient.ListPremiumPlans(); | ||
|
|
||
| // Find the password manager subscription item (seat, not storage) and match it to a plan | ||
| var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i => | ||
| premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); | ||
|
|
||
| if (passwordManagerItem == null) | ||
| { | ||
| return new BadRequest("Premium subscription item not found."); | ||
| } | ||
|
|
||
| var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); | ||
|
|
||
| // Get the target organization plan | ||
| var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); | ||
|
|
||
| // Build the list of subscription item updates | ||
| var subscriptionItemOptions = new List<SubscriptionItemOptions>(); | ||
|
|
||
| // Delete the user's specific password manager item | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Id = passwordManagerItem.Id, | ||
| Deleted = true | ||
| }); | ||
|
|
||
| // Delete the storage item if it exists for this user's plan | ||
| var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => | ||
| i.Price.Id == usersPremiumPlan.Storage.StripePriceId); | ||
|
|
||
| // Capture the previous additional storage quantity for potential revert | ||
| var previousAdditionalStorage = storageItem?.Quantity ?? 0; | ||
|
|
||
| if (storageItem != null) | ||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Id = storageItem.Id, | ||
| Deleted = true | ||
| }); | ||
| } | ||
|
|
||
| // Add new organization subscription items | ||
| if (targetPlan.HasNonSeatBasedPasswordManagerPlan()) | ||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Price = targetPlan.PasswordManager.StripePlanId, | ||
| Quantity = 1 | ||
| }); | ||
| } | ||
| else | ||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Price = targetPlan.PasswordManager.StripeSeatPlanId, | ||
| Quantity = seats | ||
| }); | ||
| } | ||
|
|
||
| // Generate organization ID early to include in metadata | ||
| var organizationId = CoreHelpers.GenerateComb(); | ||
|
|
||
| // Build the subscription update options | ||
| var subscriptionUpdateOptions = new SubscriptionUpdateOptions | ||
| { | ||
| Items = subscriptionItemOptions, | ||
| ProrationBehavior = StripeConstants.ProrationBehavior.None, | ||
| Metadata = new Dictionary<string, string> | ||
amorask-bitwarden marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| [StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(), | ||
| [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId, | ||
| [StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty, | ||
| [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(), | ||
| [StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(), | ||
| [StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User | ||
| } | ||
sbrown-livefront marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| // Create the Organization entity | ||
| var organization = new Organization | ||
| { | ||
| Id = organizationId, | ||
| Name = organizationName, | ||
| BillingEmail = user.Email, | ||
| PlanType = targetPlan.Type, | ||
| Seats = (short)seats, | ||
| MaxCollections = targetPlan.PasswordManager.MaxCollections, | ||
| MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb, | ||
| UsePolicies = targetPlan.HasPolicies, | ||
| UseSso = targetPlan.HasSso, | ||
| UseGroups = targetPlan.HasGroups, | ||
| UseEvents = targetPlan.HasEvents, | ||
| UseDirectory = targetPlan.HasDirectory, | ||
| UseTotp = targetPlan.HasTotp, | ||
| Use2fa = targetPlan.Has2fa, | ||
| UseApi = targetPlan.HasApi, | ||
| UseResetPassword = targetPlan.HasResetPassword, | ||
| SelfHost = targetPlan.HasSelfHost, | ||
| UsersGetPremium = targetPlan.UsersGetPremium, | ||
| UseCustomPermissions = targetPlan.HasCustomPermissions, | ||
| UseScim = targetPlan.HasScim, | ||
| Plan = targetPlan.Name, | ||
| Gateway = GatewayType.Stripe, | ||
| Enabled = true, | ||
| LicenseKey = CoreHelpers.SecureRandomString(20), | ||
| CreationDate = DateTime.UtcNow, | ||
| RevisionDate = DateTime.UtcNow, | ||
| Status = OrganizationStatusType.Created, | ||
| UsePasswordManager = true, | ||
| UseSecretsManager = false, | ||
| UseOrganizationDomains = targetPlan.HasOrganizationDomains, | ||
| GatewayCustomerId = user.GatewayCustomerId, | ||
| GatewaySubscriptionId = currentSubscription.Id | ||
| }; | ||
|
|
||
| // Update the subscription in Stripe | ||
| await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions); | ||
|
|
||
| // Save the organization | ||
| await organizationRepository.CreateAsync(organization); | ||
|
|
||
| // Create organization API key | ||
| await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey | ||
| { | ||
| OrganizationId = organization.Id, | ||
| ApiKey = CoreHelpers.SecureRandomString(30), | ||
amorask-bitwarden marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Type = OrganizationApiKeyType.Default, | ||
| RevisionDate = DateTime.UtcNow, | ||
| }); | ||
|
|
||
| // Update cache | ||
| await applicationCacheService.UpsertOrganizationAbilityAsync(organization); | ||
|
|
||
| // Create OrganizationUser for the upgrading user as owner | ||
| var organizationUser = new OrganizationUser | ||
| { | ||
| OrganizationId = organization.Id, | ||
| UserId = user.Id, | ||
| Key = key, | ||
| AccessSecretsManager = false, | ||
| Type = OrganizationUserType.Owner, | ||
| Status = OrganizationUserStatusType.Confirmed, | ||
| CreationDate = organization.CreationDate, | ||
| RevisionDate = organization.CreationDate | ||
| }; | ||
| organizationUser.SetNewId(); | ||
| await organizationUserRepository.CreateAsync(organizationUser); | ||
|
|
||
| // Remove subscription from user | ||
| user.Premium = false; | ||
| user.PremiumExpirationDate = null; | ||
| user.GatewaySubscriptionId = null; | ||
| user.GatewayCustomerId = null; | ||
| user.RevisionDate = DateTime.UtcNow; | ||
| await userService.SaveUserAsync(user); | ||
|
|
||
| return new None(); | ||
| }); | ||
| } | ||
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
Oops, something went wrong.
Oops, something went wrong.
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.
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.
โ Would we not include seats in the request(even though we assume it will be 1 for now)?
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.
@sbrown-livefront I recommended not doing so - since the business requirements are for a 1-seat upgrade, I feel like that would primarily just give the client extra, unnecessary responsibility.