using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Premium.Commands; 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.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; namespace Bit.Core.Test.Billing.Premium.Commands; public class UpgradePremiumToOrganizationCommandTests { // Concrete test implementation of the abstract Plan record private record TestPlan : Core.Models.StaticStore.Plan { public TestPlan( PlanType planType, string? stripePlanId = null, string? stripeSeatPlanId = null, string? stripePremiumAccessPlanId = null, string? stripeStoragePlanId = null) { Type = planType; ProductTier = ProductTierType.Teams; Name = "Test Plan"; IsAnnual = true; NameLocalizationKey = ""; DescriptionLocalizationKey = ""; CanBeUsedByBusiness = true; TrialPeriodDays = null; HasSelfHost = false; HasPolicies = false; HasGroups = false; HasDirectory = false; HasEvents = false; HasTotp = false; Has2fa = false; HasApi = false; HasSso = false; HasOrganizationDomains = false; HasKeyConnector = false; HasScim = false; HasResetPassword = false; UsersGetPremium = false; HasCustomPermissions = false; UpgradeSortOrder = 0; DisplaySortOrder = 0; LegacyYear = null; Disabled = false; PasswordManager = new PasswordManagerPlanFeatures { StripePlanId = stripePlanId, StripeSeatPlanId = stripeSeatPlanId, StripePremiumAccessPlanId = stripePremiumAccessPlanId, StripeStoragePlanId = stripeStoragePlanId, BasePrice = 0, SeatPrice = 0, ProviderPortalSeatPrice = 0, AllowSeatAutoscale = true, HasAdditionalSeatsOption = true, BaseSeats = 1, HasPremiumAccessOption = !string.IsNullOrEmpty(stripePremiumAccessPlanId), PremiumAccessOptionPrice = 0, MaxSeats = null, BaseStorageGb = 1, HasAdditionalStorageOption = !string.IsNullOrEmpty(stripeStoragePlanId), AdditionalStoragePricePerGb = 0, MaxCollections = null }; SecretsManager = null; } } private static Core.Models.StaticStore.Plan CreateTestPlan( PlanType planType, string? stripePlanId = null, string? stripeSeatPlanId = null, string? stripePremiumAccessPlanId = null, string? stripeStoragePlanId = null) { return new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId); } private static PremiumPlan CreateTestPremiumPlan( string seatPriceId = "premium-annually", string storagePriceId = "personal-storage-gb-annually", bool available = true) { return new PremiumPlan { Name = "Premium", LegacyYear = null, Available = available, Seat = new PremiumPurchasable { StripePriceId = seatPriceId, Price = 10m, Provided = 1 }, Storage = new PremiumPurchasable { StripePriceId = storagePriceId, Price = 4m, Provided = 1 } }; } private static List CreateTestPremiumPlansList() { return new List { // Current available plan CreateTestPremiumPlan("premium-annually", "personal-storage-gb-annually", available: true), // Legacy plan from 2020 CreateTestPremiumPlan("premium-annually-2020", "personal-storage-gb-annually-2020", available: false) }; } private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly IUserService _userService = Substitute.For(); private readonly IOrganizationRepository _organizationRepository = Substitute.For(); private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository = Substitute.For(); private readonly IApplicationCacheService _applicationCacheService = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly UpgradePremiumToOrganizationCommand _command; public UpgradePremiumToOrganizationCommandTests() { _command = new UpgradePremiumToOrganizationCommand( _logger, _pricingClient, _stripeAdapter, _userService, _organizationRepository, _organizationUserRepository, _organizationApiKeyRepository, _applicationCacheService); } [Theory, BitAutoData] public async Task Run_UserNotPremium_ReturnsBadRequest(User user) { // Arrange user.Premium = false; // Act var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); // Assert Assert.True(result.IsT1); var badRequest = result.AsT1; Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); } [Theory, BitAutoData] public async Task Run_UserNoGatewaySubscriptionId_ReturnsBadRequest(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = null; // Act var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); // Assert Assert.True(result.IsT1); var badRequest = result.AsT1; Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); } [Theory, BitAutoData] public async Task Run_UserEmptyGatewaySubscriptionId_ReturnsBadRequest(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = ""; // Act var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); // Assert Assert.True(result.IsT1); var badRequest = result.AsT1; Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); } [Theory, BitAutoData] public async Task Run_SuccessfulUpgrade_SeatBasedPlan_ReturnsSuccess(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = "sub_123"; user.GatewayCustomerId = "cus_123"; user.Id = Guid.NewGuid(); var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); var mockSubscription = new Subscription { Id = "sub_123", Items = new StripeList { Data = new List { new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } } }, Metadata = new Dictionary() }; var mockPremiumPlans = CreateTestPremiumPlansList(); var mockPlan = CreateTestPlan( PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually" ); _stripeAdapter.GetSubscriptionAsync("sub_123") .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); // Assert Assert.True(result.IsT0); await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => opts.Items.Count == 2 && // 1 deleted + 1 seat (no storage) opts.Items.Any(i => i.Deleted == true) && opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => o.Name == "My Organization" && o.Gateway == GatewayType.Stripe && o.GatewaySubscriptionId == "sub_123" && o.GatewayCustomerId == "cus_123")); await _organizationUserRepository.Received(1).CreateAsync(Arg.Is(ou => ou.Key == "encrypted-key" && ou.Status == OrganizationUserStatusType.Confirmed)); await _organizationApiKeyRepository.Received(1).CreateAsync(Arg.Any()); await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.Premium == false && u.GatewaySubscriptionId == null && u.GatewayCustomerId == null)); } [Theory, BitAutoData] public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = "sub_123"; user.GatewayCustomerId = "cus_123"; var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); var mockSubscription = new Subscription { Id = "sub_123", Items = new StripeList { Data = new List { new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } } }, Metadata = new Dictionary() }; var mockPremiumPlans = CreateTestPremiumPlansList(); var mockPlan = CreateTestPlan( PlanType.FamiliesAnnually, stripePlanId: "families-plan-annually", stripeSeatPlanId: null // Non-seat-based ); _stripeAdapter.GetSubscriptionAsync("sub_123") .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(mockPlan); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually); // Assert Assert.True(result.IsT0); await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => opts.Items.Count == 2 && // 1 deleted + 1 plan opts.Items.Any(i => i.Deleted == true) && opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1))); await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => o.Name == "My Families Org")); await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.Premium == false && u.GatewaySubscriptionId == null)); } [Theory, BitAutoData] public async Task Run_AddsMetadataWithOriginalPremiumPriceId(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = "sub_123"; var mockSubscription = new Subscription { Id = "sub_123", Items = new StripeList { Data = new List { new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } } }, Metadata = new Dictionary { ["userId"] = user.Id.ToString() } }; var mockPremiumPlans = CreateTestPremiumPlansList(); var mockPlan = CreateTestPlan( PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually" ); _stripeAdapter.GetSubscriptionAsync("sub_123") .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); // Assert Assert.True(result.IsT0); await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) && opts.Metadata[StripeConstants.MetadataKeys.PreviousPremiumPriceId] == "premium-annually" && opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) && opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "0" && opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) && opts.Metadata[StripeConstants.MetadataKeys.UserId] == string.Empty)); // Removes userId to unlink from User } [Theory, BitAutoData] public async Task Run_UserOnLegacyPremiumPlan_SuccessfullyDeletesLegacyItems(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = "sub_123"; user.GatewayCustomerId = "cus_123"; var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); var mockSubscription = new Subscription { Id = "sub_123", Items = new StripeList { Data = new List { new SubscriptionItem { Id = "si_premium_legacy", Price = new Price { Id = "premium-annually-2020" }, // Legacy price ID CurrentPeriodEnd = currentPeriodEnd }, new SubscriptionItem { Id = "si_storage_legacy", Price = new Price { Id = "personal-storage-gb-annually-2020" }, // Legacy storage price ID CurrentPeriodEnd = currentPeriodEnd } } }, Metadata = new Dictionary() }; var mockPremiumPlans = CreateTestPremiumPlansList(); var mockPlan = CreateTestPlan( PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually" ); _stripeAdapter.GetSubscriptionAsync("sub_123") .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); // Assert Assert.True(result.IsT0); // Verify that BOTH legacy items (password manager + storage) are deleted by ID await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => opts.Items.Count == 3 && // 2 deleted (legacy PM + legacy storage) + 1 new seat opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium_legacy") == 1 && // Legacy PM deleted opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1 && // Legacy storage deleted opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); } [Theory, BitAutoData] public async Task Run_UserHasPremiumPlusOtherProducts_OnlyDeletesPremiumItems(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = "sub_123"; user.GatewayCustomerId = "cus_123"; var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); var mockSubscription = new Subscription { Id = "sub_123", Items = new StripeList { Data = new List { new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd }, new SubscriptionItem { Id = "si_other_product", Price = new Price { Id = "some-other-product-id" }, // Non-premium item CurrentPeriodEnd = currentPeriodEnd } } }, Metadata = new Dictionary() }; var mockPremiumPlans = CreateTestPremiumPlansList(); var mockPlan = CreateTestPlan( PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually" ); _stripeAdapter.GetSubscriptionAsync("sub_123") .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); // Assert Assert.True(result.IsT0); // Verify that ONLY the premium password manager item is deleted (not other products) // Note: We delete the specific premium item by ID, so other products are untouched await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => opts.Items.Count == 2 && // 1 deleted (premium password manager) + 1 new seat opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium") == 1 && // Premium item deleted by ID opts.Items.Count(i => i.Id == "si_other_product") == 0 && // Other product NOT in update (untouched) opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); } [Theory, BitAutoData] public async Task Run_UserHasAdditionalStorage_CapturesStorageInMetadata(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = "sub_123"; user.GatewayCustomerId = "cus_123"; var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); var mockSubscription = new Subscription { Id = "sub_123", Items = new StripeList { Data = new List { new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd }, new SubscriptionItem { Id = "si_storage", Price = new Price { Id = "personal-storage-gb-annually" }, Quantity = 5, // User has 5GB additional storage CurrentPeriodEnd = currentPeriodEnd } } }, Metadata = new Dictionary() }; var mockPremiumPlans = CreateTestPremiumPlansList(); var mockPlan = CreateTestPlan( PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually" ); _stripeAdapter.GetSubscriptionAsync("sub_123") .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); // Assert Assert.True(result.IsT0); // Verify that the additional storage quantity (5) is captured in metadata await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" && opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat opts.Items.Count(i => i.Deleted == true) == 2)); } [Theory, BitAutoData] public async Task Run_NoPremiumSubscriptionItemFound_ReturnsBadRequest(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = "sub_123"; var mockSubscription = new Subscription { Id = "sub_123", Items = new StripeList { Data = new List { new SubscriptionItem { Id = "si_other", Price = new Price { Id = "some-other-product" }, // Not a premium plan CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } } }, Metadata = new Dictionary() }; var mockPremiumPlans = CreateTestPremiumPlansList(); _stripeAdapter.GetSubscriptionAsync("sub_123") .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); // Act var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); // Assert Assert.True(result.IsT1); var badRequest = result.AsT1; Assert.Equal("Premium subscription item not found.", badRequest.Response); } }