diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 08c0da08b..d6bee10f3 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -449,6 +449,119 @@ public class ProviderBillingService( } } + public async Task UpdateSeatMinimums( + Provider provider, + int enterpriseSeatMinimum, + int teamsSeatMinimum) + { + ArgumentNullException.ThrowIfNull(provider); + + if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0) + { + throw new BadRequestException("Provider seat minimums must be at least 0."); + } + + var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId); + + var subscriptionItemOptionsList = new List(); + + var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + + var enterpriseProviderPlan = + providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly); + + if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum) + { + var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager + .StripeProviderPortalSeatPlanId; + + var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId); + + if (enterpriseProviderPlan.PurchasedSeats == 0) + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Id = enterpriseSubscriptionItem.Id, + Price = enterprisePriceId, + Quantity = enterpriseSeatMinimum + }); + } + else + { + var totalEnterpriseSeats = enterpriseProviderPlan.SeatMinimum + enterpriseProviderPlan.PurchasedSeats; + + if (enterpriseSeatMinimum <= totalEnterpriseSeats) + { + enterpriseProviderPlan.PurchasedSeats = totalEnterpriseSeats - enterpriseSeatMinimum; + } + else + { + enterpriseProviderPlan.PurchasedSeats = 0; + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Id = enterpriseSubscriptionItem.Id, + Price = enterprisePriceId, + Quantity = enterpriseSeatMinimum + }); + } + } + + enterpriseProviderPlan.SeatMinimum = enterpriseSeatMinimum; + + await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan); + } + + var teamsProviderPlan = + providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly); + + if (teamsProviderPlan.SeatMinimum != teamsSeatMinimum) + { + var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager + .StripeProviderPortalSeatPlanId; + + var teamsSubscriptionItem = subscription.Items.First(item => item.Price.Id == teamsPriceId); + + if (teamsProviderPlan.PurchasedSeats == 0) + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Id = teamsSubscriptionItem.Id, + Price = teamsPriceId, + Quantity = teamsSeatMinimum + }); + } + else + { + var totalTeamsSeats = teamsProviderPlan.SeatMinimum + teamsProviderPlan.PurchasedSeats; + + if (teamsSeatMinimum <= totalTeamsSeats) + { + teamsProviderPlan.PurchasedSeats = totalTeamsSeats - teamsSeatMinimum; + } + else + { + teamsProviderPlan.PurchasedSeats = 0; + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Id = teamsSubscriptionItem.Id, + Price = teamsPriceId, + Quantity = teamsSeatMinimum + }); + } + } + + teamsProviderPlan.SeatMinimum = teamsSeatMinimum; + + await providerPlanRepository.ReplaceAsync(teamsProviderPlan); + } + + if (subscriptionItemOptionsList.Count > 0) + { + await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList }); + } + } + private Func CurrySeatScalingUpdate( Provider provider, ProviderPlan providerPlan, diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index 0aa1a164f..45a6fb983 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -16,6 +16,7 @@ using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; @@ -1009,4 +1010,259 @@ public class ProviderBillingServiceTests } #endregion + + #region UpdateSeatMinimums + + [Theory, BitAutoData] + public async Task UpdateSeatMinimums_NullProvider_ThrowsArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSeatMinimums(null, 0, 0)); + + [Theory, BitAutoData] + public async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestException( + Provider provider, + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSeatMinimums(provider, -10, 100)); + + [Theory, BitAutoData] + public async Task UpdateSeatMinimums_NoPurchasedSeats_SyncsStripeWithNewSeatMinimum( + Provider provider, + SutProvider sutProvider) + { + var stripeAdapter = sutProvider.GetDependency(); + var providerPlanRepository = sutProvider.GetDependency(); + + const string enterpriseLineItemId = "enterprise_line_item_id"; + const string teamsLineItemId = "teams_line_item_id"; + + var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Id = enterpriseLineItemId, + Price = new Price { Id = enterprisePriceId } + }, + new SubscriptionItem + { + Id = teamsLineItemId, + Price = new Price { Id = teamsPriceId } + } + ] + } + }; + + stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); + + var providerPlans = new List + { + new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 0 }, + new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0 } + }; + + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); + + await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 50); + + await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( + providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); + + await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( + providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 50)); + + await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + Arg.Is( + options => + options.Items.Count == 2 && + options.Items.ElementAt(0).Id == enterpriseLineItemId && + options.Items.ElementAt(0).Quantity == 70 && + options.Items.ElementAt(1).Id == teamsLineItemId && + options.Items.ElementAt(1).Quantity == 50)); + } + + [Theory, BitAutoData] + public async Task UpdateSeatMinimums_PurchasedSeats_NewMinimumLessThanTotal_UpdatesPurchasedSeats( + Provider provider, + SutProvider sutProvider) + { + var stripeAdapter = sutProvider.GetDependency(); + var providerPlanRepository = sutProvider.GetDependency(); + + const string enterpriseLineItemId = "enterprise_line_item_id"; + const string teamsLineItemId = "teams_line_item_id"; + + var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Id = enterpriseLineItemId, + Price = new Price { Id = enterprisePriceId } + }, + new SubscriptionItem + { + Id = teamsLineItemId, + Price = new Price { Id = teamsPriceId } + } + ] + } + }; + + stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); + + var providerPlans = new List + { + new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 20 }, + new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 } + }; + + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); + + await sutProvider.Sut.UpdateSeatMinimums(provider, 60, 60); + + await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( + providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10)); + + await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( + providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10)); + + await stripeAdapter.DidNotReceiveWithAnyArgs() + .SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateSeatMinimums_PurchasedSeats_NewMinimumGreaterThanTotal_ClearsPurchasedSeats_SyncsStripeWithNewSeatMinimum( + Provider provider, + SutProvider sutProvider) + { + var stripeAdapter = sutProvider.GetDependency(); + var providerPlanRepository = sutProvider.GetDependency(); + + const string enterpriseLineItemId = "enterprise_line_item_id"; + const string teamsLineItemId = "teams_line_item_id"; + + var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Id = enterpriseLineItemId, + Price = new Price { Id = enterprisePriceId } + }, + new SubscriptionItem + { + Id = teamsLineItemId, + Price = new Price { Id = teamsPriceId } + } + ] + } + }; + + stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); + + var providerPlans = new List + { + new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 20 }, + new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 } + }; + + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); + + await sutProvider.Sut.UpdateSeatMinimums(provider, 80, 80); + + await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( + providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0)); + + await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( + providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0)); + + await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + Arg.Is( + options => + options.Items.Count == 2 && + options.Items.ElementAt(0).Id == enterpriseLineItemId && + options.Items.ElementAt(0).Quantity == 80 && + options.Items.ElementAt(1).Id == teamsLineItemId && + options.Items.ElementAt(1).Quantity == 80)); + } + + [Theory, BitAutoData] + public async Task UpdateSeatMinimums_SinglePlanTypeUpdate_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var stripeAdapter = sutProvider.GetDependency(); + var providerPlanRepository = sutProvider.GetDependency(); + + const string enterpriseLineItemId = "enterprise_line_item_id"; + const string teamsLineItemId = "teams_line_item_id"; + + var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Id = enterpriseLineItemId, + Price = new Price { Id = enterprisePriceId } + }, + new SubscriptionItem + { + Id = teamsLineItemId, + Price = new Price { Id = teamsPriceId } + } + ] + } + }; + + stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); + + var providerPlans = new List + { + new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 0 }, + new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0 } + }; + + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); + + await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 30); + + await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( + providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); + + await providerPlanRepository.DidNotReceive().ReplaceAsync(Arg.Is( + providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)); + + await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + Arg.Is( + options => + options.Items.Count == 1 && + options.Items.ElementAt(0).Id == enterpriseLineItemId && + options.Items.ElementAt(0).Quantity == 70)); + } + + #endregion } diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 8eb28e24a..9b0f34924 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -13,6 +13,7 @@ using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -40,6 +41,7 @@ public class ProvidersController : Controller private readonly ICreateProviderCommand _createProviderCommand; private readonly IFeatureService _featureService; private readonly IProviderPlanRepository _providerPlanRepository; + private readonly IProviderBillingService _providerBillingService; private readonly string _stripeUrl; private readonly string _braintreeMerchantUrl; private readonly string _braintreeMerchantId; @@ -57,6 +59,7 @@ public class ProvidersController : Controller ICreateProviderCommand createProviderCommand, IFeatureService featureService, IProviderPlanRepository providerPlanRepository, + IProviderBillingService providerBillingService, IWebHostEnvironment webHostEnvironment) { _organizationRepository = organizationRepository; @@ -71,6 +74,7 @@ public class ProvidersController : Controller _createProviderCommand = createProviderCommand; _featureService = featureService; _providerPlanRepository = providerPlanRepository; + _providerBillingService = providerBillingService; _stripeUrl = webHostEnvironment.GetStripeUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; @@ -223,19 +227,10 @@ public class ProvidersController : Controller } else { - foreach (var providerPlan in providerPlans) - { - if (providerPlan.PlanType == PlanType.EnterpriseMonthly) - { - providerPlan.SeatMinimum = model.EnterpriseMonthlySeatMinimum; - } - else if (providerPlan.PlanType == PlanType.TeamsMonthly) - { - providerPlan.SeatMinimum = model.TeamsMonthlySeatMinimum; - } - - await _providerPlanRepository.ReplaceAsync(providerPlan); - } + await _providerBillingService.UpdateSeatMinimums( + provider, + model.EnterpriseMonthlySeatMinimum, + model.TeamsMonthlySeatMinimum); } return RedirectToAction("Edit", new { id }); diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index 0d136b503..1235fcd5f 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -88,4 +88,9 @@ public interface IProviderBillingService /// This method requires the to already have a linked Stripe via its field. Task SetupSubscription( Provider provider); + + Task UpdateSeatMinimums( + Provider provider, + int enterpriseSeatMinimum, + int teamsSeatMinimum); }