diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 7dd5e603dea8..d1e9b9206a1d 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -25,7 +25,8 @@ public class AccountBillingVNextController( IGetPaymentMethodQuery getPaymentMethodQuery, IGetUserLicenseQuery getUserLicenseQuery, IUpdatePaymentMethodCommand updatePaymentMethodCommand, - IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController + IUpdatePremiumStorageCommand updatePremiumStorageCommand, + IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController { [HttpGet("credit")] [InjectUser] @@ -100,4 +101,15 @@ public async Task UpdateStorageAsync( var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb); return Handle(result); } + + [HttpPost("upgrade")] + [InjectUser] + public async Task UpgradePremiumToOrganizationAsync( + [BindNever] User user, + [FromBody] UpgradePremiumToOrganizationRequest request) + { + var (organizationName, key, planType) = request.ToDomain(); + var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType); + return Handle(result); + } } diff --git a/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs new file mode 100644 index 000000000000..14375efc786f --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs @@ -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); +} diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index dc128127aeee..d25962a7ba9b 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -67,6 +67,10 @@ public static class MetadataKeys public const string BraintreeCustomerId = "btCustomerId"; public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; + public const string PreviousAdditionalStorage = "previous_additional_storage"; + public const string PreviousPeriodEndDate = "previous_period_end_date"; + public const string PreviousPremiumPriceId = "previous_premium_price_id"; + public const string PreviousPremiumUserId = "previous_premium_user_id"; public const string ProviderId = "providerId"; public const string Region = "region"; public const string RetiredBraintreeCustomerId = "btCustomerId_old"; diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 3d63a35406b4..d121ab04aa83 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -54,6 +54,7 @@ private static void AddPremiumCommands(this IServiceCollection services) services.AddScoped(); services.AddTransient(); services.AddScoped(); + services.AddScoped(); } private static void AddPremiumQueries(this IServiceCollection services) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs new file mode 100644 index 000000000000..81bc5c9e2cca --- /dev/null +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -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; +/// +/// 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. +/// +public interface IUpgradePremiumToOrganizationCommand +{ + /// + /// Upgrades a Premium subscription to an Organization subscription. + /// + /// The user with an active Premium subscription to upgrade. + /// The name for the new organization. + /// The encrypted organization key for the owner. + /// The target organization plan type to upgrade to. + /// A billing command result indicating success or failure with appropriate error details. + Task> Run( + User user, + string organizationName, + string key, + PlanType targetPlanType); +} + +public class UpgradePremiumToOrganizationCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter, + IUserService userService, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService) + : BaseBillingCommand(logger), IUpgradePremiumToOrganizationCommand +{ + public Task> Run( + User user, + string organizationName, + string key, + PlanType targetPlanType) => HandleAsync(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(); + + // 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 + { + [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 + } + }; + + // 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), + 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(); + }); +} diff --git a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs index 66d1a4d3e177..653785b14327 100644 --- a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs @@ -17,12 +17,14 @@ public class AccountBillingVNextControllerTests { private readonly IUpdatePremiumStorageCommand _updatePremiumStorageCommand; private readonly IGetUserLicenseQuery _getUserLicenseQuery; + private readonly IUpgradePremiumToOrganizationCommand _upgradePremiumToOrganizationCommand; private readonly AccountBillingVNextController _sut; public AccountBillingVNextControllerTests() { _updatePremiumStorageCommand = Substitute.For(); _getUserLicenseQuery = Substitute.For(); + _upgradePremiumToOrganizationCommand = Substitute.For(); _sut = new AccountBillingVNextController( Substitute.For(), @@ -31,7 +33,8 @@ public AccountBillingVNextControllerTests() Substitute.For(), _getUserLicenseQuery, Substitute.For(), - _updatePremiumStorageCommand); + _updatePremiumStorageCommand, + _upgradePremiumToOrganizationCommand); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs new file mode 100644 index 000000000000..e686d0400957 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -0,0 +1,646 @@ +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); + } +}