-
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
Changes from 11 commits
35d8812
8276423
ffdef93
dc93149
bca4274
64fa791
2c6c2fe
383530d
79a8be0
d8ebcc9
00f4ad6
025e3e0
9ba9ef1
0687f31
a64e0a0
9b54c2e
244ca57
cbdec08
823db8c
5299129
f728816
9b3a68f
d2f197a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| ๏ปฟ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] | ||
| [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 PlanType ToDomain() => PlanType; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| ๏ปฟ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="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, | ||
| 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, | ||
| 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 and storage to null for upgrade flow | ||
| const int seats = 1; | ||
| int? storage = null; | ||
|
|
||
| // Fetch the current Premium subscription from Stripe | ||
| var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); | ||
|
|
||
| // Get the target organization plan | ||
| var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); | ||
|
|
||
| // Build the list of subscription item updates | ||
| var subscriptionItemOptions = new List<SubscriptionItemOptions>(); | ||
|
|
||
| // Mark existing Premium subscription items for deletion | ||
| // Only delete Premium and storage items, not other potential subscription items | ||
| foreach (var item in currentSubscription.Items.Data) | ||
|
||
| { | ||
| var priceId = item.Price.Id; | ||
| var isPremiumItem = priceId == StripeConstants.Prices.PremiumAnnually; | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| var isStorageItem = priceId == StripeConstants.Prices.StoragePlanPersonal; | ||
|
|
||
| if (isPremiumItem || isStorageItem) | ||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Id = item.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 | ||
| }); | ||
| } | ||
|
|
||
| if (storage is > 0) | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Price = targetPlan.PasswordManager.StripeStoragePlanId, | ||
| Quantity = storage | ||
| }); | ||
| } | ||
|
|
||
| // Build the subscription update options | ||
| var subscriptionUpdateOptions = new SubscriptionUpdateOptions | ||
| { | ||
| Items = subscriptionItemOptions, | ||
| ProrationBehavior = StripeConstants.ProrationBehavior.None, | ||
| Metadata = new Dictionary<string, string>(currentSubscription.Metadata ?? new Dictionary<string, string>()) | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = currentSubscription.Items.Data | ||
| .Select(item => item.Price.Id) | ||
| .FirstOrDefault() ?? "premium", | ||
| [StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty | ||
| } | ||
sbrown-livefront marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| // Create the Organization entity | ||
| var organization = new Organization | ||
| { | ||
| Id = CoreHelpers.GenerateComb(), | ||
| Name = $"{user.Email}'s Organization", | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| BillingEmail = user.Email, | ||
| PlanType = targetPlan.Type, | ||
| Seats = (short)seats, | ||
| MaxCollections = targetPlan.PasswordManager.MaxCollections, | ||
| MaxStorageGb = (short)(targetPlan.PasswordManager.BaseStorageGb + (storage ?? 0)), | ||
| 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 = null, | ||
|
||
| 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 = null, // Will need to be set by client | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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(); | ||
| }); | ||
| } | ||
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.