From 35b0f619865ce21d0c10e238485937515b17f54c Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Mon, 4 Nov 2024 06:45:25 +0100 Subject: [PATCH] [PM-13450] Admin: Display Multi-organization Enterprise attributes on provider details (#4955) --- .../Billing/ProviderBillingService.cs | 225 ++++++++------- .../Billing/ProviderBillingServiceTests.cs | 264 ++++++++++++++++-- .../Controllers/ProvidersController.cs | 49 ++-- .../AdminConsole/Models/ProviderEditModel.cs | 40 ++- .../AdminConsole/Views/Providers/Edit.cshtml | 134 +++++---- .../Billing/Extensions/BillingExtensions.cs | 2 +- .../Implementations/ProviderMigrator.cs | 10 +- .../Contracts/ChangeProviderPlansCommand.cs | 8 + .../UpdateProviderSeatMinimumsCommand.cs | 10 + .../Services/IProviderBillingService.cs | 13 +- src/Core/Services/IStripeAdapter.cs | 11 + .../Services/Implementations/StripeAdapter.cs | 14 + 12 files changed, 578 insertions(+), 202 deletions(-) create mode 100644 src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs create mode 100644 src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 19991dab2..32698eaaf 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Services.Contracts; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -437,144 +438,142 @@ public class ProviderBillingService( } } - public async Task UpdateSeatMinimums( - Provider provider, - int enterpriseSeatMinimum, - int teamsSeatMinimum) + public async Task ChangePlan(ChangeProviderPlanCommand command) { - ArgumentNullException.ThrowIfNull(provider); + var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId); - if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0) + if (plan == null) + { + throw new BadRequestException("Provider plan not found."); + } + + if (plan.PlanType == command.NewPlan) + { + return; + } + + var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType); + + plan.PlanType = command.NewPlan; + await providerPlanRepository.ReplaceAsync(plan); + + Subscription subscription; + try + { + subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId); + } + catch (InvalidOperationException) + { + throw new ConflictException("Subscription not found."); + } + + var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => + x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId); + + var updateOptions = new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions + { + Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId, + Quantity = oldSubscriptionItem!.Quantity + }, + new SubscriptionItemOptions + { + Id = oldSubscriptionItem.Id, + Deleted = true + } + ] + }; + + await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions); + } + + public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) + { + if (command.Configuration.Any(x => x.SeatsMinimum < 0)) { throw new BadRequestException("Provider seat minimums must be at least 0."); } - var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId); + Subscription subscription; + try + { + subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id); + } + catch (InvalidOperationException) + { + throw new ConflictException("Subscription not found."); + } var subscriptionItemOptionsList = new List(); - var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + var providerPlans = await providerPlanRepository.GetByProviderId(command.Id); - var enterpriseProviderPlan = - providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly); - - if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum) + foreach (var newPlanConfiguration in command.Configuration) { - var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager - .StripeProviderPortalSeatPlanId; + var providerPlan = + providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan); - var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId); - - if (enterpriseProviderPlan.PurchasedSeats == 0) + if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum) { - if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum) - { - enterpriseProviderPlan.PurchasedSeats = - enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum; + var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager + .StripeProviderPortalSeatPlanId; + var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId); - subscriptionItemOptionsList.Add(new SubscriptionItemOptions + if (providerPlan.PurchasedSeats == 0) + { + if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum) { - Id = enterpriseSubscriptionItem.Id, - Price = enterprisePriceId, - Quantity = enterpriseProviderPlan.AllocatedSeats - }); + providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum; + + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Id = subscriptionItem.Id, + Price = priceId, + Quantity = providerPlan.AllocatedSeats + }); + } + else + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Id = subscriptionItem.Id, + Price = priceId, + Quantity = newPlanConfiguration.SeatsMinimum + }); + } } else { - subscriptionItemOptionsList.Add(new SubscriptionItemOptions + var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats; + + if (newPlanConfiguration.SeatsMinimum <= totalSeats) { - Id = enterpriseSubscriptionItem.Id, - Price = enterprisePriceId, - Quantity = enterpriseSeatMinimum - }); + providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum; + } + else + { + providerPlan.PurchasedSeats = 0; + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Id = subscriptionItem.Id, + Price = priceId, + Quantity = newPlanConfiguration.SeatsMinimum + }); + } } + + providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum; + + await providerPlanRepository.ReplaceAsync(providerPlan); } - 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) - { - if (teamsProviderPlan.AllocatedSeats > teamsSeatMinimum) - { - teamsProviderPlan.PurchasedSeats = teamsProviderPlan.AllocatedSeats - teamsSeatMinimum; - - subscriptionItemOptionsList.Add(new SubscriptionItemOptions - { - Id = teamsSubscriptionItem.Id, - Price = teamsPriceId, - Quantity = teamsProviderPlan.AllocatedSeats - }); - } - else - { - 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, + await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList }); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index d9ae9a559..7c3e8cad8 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Services.Contracts; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -1011,26 +1012,192 @@ public class ProviderBillingServiceTests #endregion - #region UpdateSeatMinimums + #region ChangePlan [Theory, BitAutoData] - public async Task UpdateSeatMinimums_NullProvider_ThrowsArgumentNullException( - SutProvider sutProvider) => - await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSeatMinimums(null, 0, 0)); + public async Task ChangePlan_NullProviderPlan_ThrowsBadRequestException( + ChangeProviderPlanCommand command, + SutProvider sutProvider) + { + // Arrange + var providerPlanRepository = sutProvider.GetDependency(); + providerPlanRepository.GetByIdAsync(Arg.Any()).Returns((ProviderPlan)null); + + // Act + var actual = await Assert.ThrowsAsync(() => sutProvider.Sut.ChangePlan(command)); + + // Assert + Assert.Equal("Provider plan not found.", actual.Message); + } + + [Theory, BitAutoData] + public async Task ChangePlan_ProviderNotFound_DoesNothing( + ChangeProviderPlanCommand command, + SutProvider sutProvider) + { + // Arrange + var providerPlanRepository = sutProvider.GetDependency(); + var stripeAdapter = sutProvider.GetDependency(); + var existingPlan = new ProviderPlan + { + Id = command.ProviderPlanId, + PlanType = command.NewPlan, + PurchasedSeats = 0, + AllocatedSeats = 0, + SeatMinimum = 0 + }; + providerPlanRepository + .GetByIdAsync(Arg.Is(p => p == command.ProviderPlanId)) + .Returns(existingPlan); + + // Act + await sutProvider.Sut.ChangePlan(command); + + // Assert + await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any()); + await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ChangePlan_SameProviderPlan_DoesNothing( + ChangeProviderPlanCommand command, + SutProvider sutProvider) + { + // Arrange + var providerPlanRepository = sutProvider.GetDependency(); + var stripeAdapter = sutProvider.GetDependency(); + var existingPlan = new ProviderPlan + { + Id = command.ProviderPlanId, + PlanType = command.NewPlan, + PurchasedSeats = 0, + AllocatedSeats = 0, + SeatMinimum = 0 + }; + providerPlanRepository + .GetByIdAsync(Arg.Is(p => p == command.ProviderPlanId)) + .Returns(existingPlan); + + // Act + await sutProvider.Sut.ChangePlan(command); + + // Assert + await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any()); + await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ChangePlan_UpdatesSubscriptionCorrectly( + Guid providerPlanId, + Provider provider, + SutProvider sutProvider) + { + // Arrange + var providerPlanRepository = sutProvider.GetDependency(); + var existingPlan = new ProviderPlan + { + Id = providerPlanId, + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseAnnually, + PurchasedSeats = 2, + AllocatedSeats = 10, + SeatMinimum = 8 + }; + providerPlanRepository + .GetByIdAsync(Arg.Is(p => p == providerPlanId)) + .Returns(existingPlan); + + var providerRepository = sutProvider.GetDependency(); + providerRepository.GetByIdAsync(Arg.Is(existingPlan.ProviderId)).Returns(provider); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.ProviderSubscriptionGetAsync( + Arg.Is(provider.GatewaySubscriptionId), + Arg.Is(provider.Id)) + .Returns(new Subscription + { + Id = provider.GatewaySubscriptionId, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Id = "si_ent_annual", + Price = new Price + { + Id = StaticStore.GetPlan(PlanType.EnterpriseAnnually).PasswordManager + .StripeProviderPortalSeatPlanId + }, + Quantity = 10 + } + ] + } + }); + + var command = + new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId); + + // Act + await sutProvider.Sut.ChangePlan(command); + + // Assert + await providerPlanRepository.Received(1) + .ReplaceAsync(Arg.Is(p => p.PlanType == PlanType.EnterpriseMonthly)); + + await stripeAdapter.Received(1) + .SubscriptionUpdateAsync( + Arg.Is(provider.GatewaySubscriptionId), + Arg.Is(p => + p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1)); + + var newPlanCfg = StaticStore.GetPlan(command.NewPlan); + await stripeAdapter.Received(1) + .SubscriptionUpdateAsync( + Arg.Is(provider.GatewaySubscriptionId), + Arg.Is(p => + p.Items.Count(si => + si.Price == newPlanCfg.PasswordManager.StripeProviderPortalSeatPlanId && + si.Deleted == default && + si.Quantity == 10) == 1)); + } + + #endregion + + #region UpdateSeatMinimums [Theory, BitAutoData] public async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestException( Provider provider, - SutProvider sutProvider) => - await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSeatMinimums(provider, -10, 100)); + SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + providerRepository.GetByIdAsync(provider.Id).Returns(provider); + var command = new UpdateProviderSeatMinimumsCommand( + provider.Id, + provider.GatewaySubscriptionId, + [ + (PlanType.TeamsMonthly, -10), + (PlanType.EnterpriseMonthly, 50) + ]); + + // Act + var actual = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSeatMinimums(command)); + + // Assert + Assert.Equal("Provider seat minimums must be at least 0.", actual.Message); + } [Theory, BitAutoData] public async Task UpdateSeatMinimums_NoPurchasedSeats_AllocatedHigherThanIncomingMinimum_UpdatesPurchasedSeats_SyncsStripeWithNewSeatMinimum( Provider provider, SutProvider sutProvider) { + // Arrange var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); + var providerRepository = sutProvider.GetDependency(); const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; @@ -1058,7 +1225,9 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); + stripeAdapter.ProviderSubscriptionGetAsync( + provider.GatewaySubscriptionId, + provider.Id).Returns(subscription); var providerPlans = new List { @@ -1066,10 +1235,21 @@ public class ProviderBillingServiceTests new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 25 } }; + providerRepository.GetByIdAsync(provider.Id).Returns(provider); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); - await sutProvider.Sut.UpdateSeatMinimums(provider, 30, 20); + var command = new UpdateProviderSeatMinimumsCommand( + provider.Id, + provider.GatewaySubscriptionId, + [ + (PlanType.EnterpriseMonthly, 30), + (PlanType.TeamsMonthly, 20) + ]); + // Act + await sutProvider.Sut.UpdateSeatMinimums(command); + + // Assert await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 30)); @@ -1091,8 +1271,11 @@ public class ProviderBillingServiceTests Provider provider, SutProvider sutProvider) { + // Arrange var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); + var providerRepository = sutProvider.GetDependency(); + providerRepository.GetByIdAsync(provider.Id).Returns(provider); const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; @@ -1120,7 +1303,7 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); + stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription); var providerPlans = new List { @@ -1130,8 +1313,18 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); - await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 50); + var command = new UpdateProviderSeatMinimumsCommand( + provider.Id, + provider.GatewaySubscriptionId, + [ + (PlanType.EnterpriseMonthly, 70), + (PlanType.TeamsMonthly, 50) + ]); + // Act + await sutProvider.Sut.UpdateSeatMinimums(command); + + // Assert await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); @@ -1153,8 +1346,11 @@ public class ProviderBillingServiceTests Provider provider, SutProvider sutProvider) { + // Arrange var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); + var providerRepository = sutProvider.GetDependency(); + providerRepository.GetByIdAsync(provider.Id).Returns(provider); const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; @@ -1182,7 +1378,7 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); + stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription); var providerPlans = new List { @@ -1192,8 +1388,18 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); - await sutProvider.Sut.UpdateSeatMinimums(provider, 60, 60); + var command = new UpdateProviderSeatMinimumsCommand( + provider.Id, + provider.GatewaySubscriptionId, + [ + (PlanType.EnterpriseMonthly, 60), + (PlanType.TeamsMonthly, 60) + ]); + // Act + await sutProvider.Sut.UpdateSeatMinimums(command); + + // Assert await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10)); @@ -1209,8 +1415,11 @@ public class ProviderBillingServiceTests Provider provider, SutProvider sutProvider) { + // Arrange var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); + var providerRepository = sutProvider.GetDependency(); + providerRepository.GetByIdAsync(provider.Id).Returns(provider); const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; @@ -1238,7 +1447,7 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); + stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription); var providerPlans = new List { @@ -1248,8 +1457,18 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); - await sutProvider.Sut.UpdateSeatMinimums(provider, 80, 80); + var command = new UpdateProviderSeatMinimumsCommand( + provider.Id, + provider.GatewaySubscriptionId, + [ + (PlanType.EnterpriseMonthly, 80), + (PlanType.TeamsMonthly, 80) + ]); + // Act + await sutProvider.Sut.UpdateSeatMinimums(command); + + // Assert await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0)); @@ -1271,8 +1490,11 @@ public class ProviderBillingServiceTests Provider provider, SutProvider sutProvider) { + // Arrange var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); + var providerRepository = sutProvider.GetDependency(); + providerRepository.GetByIdAsync(provider.Id).Returns(provider); const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; @@ -1300,7 +1522,7 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); + stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription); var providerPlans = new List { @@ -1310,8 +1532,18 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); - await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 30); + var command = new UpdateProviderSeatMinimumsCommand( + provider.Id, + provider.GatewaySubscriptionId, + [ + (PlanType.EnterpriseMonthly, 70), + (PlanType.TeamsMonthly, 30) + ]); + // Act + await sutProvider.Sut.UpdateSeatMinimums(command); + + // Assert await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index a7c49b214..83e4ce7d5 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -14,6 +14,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Services.Contracts; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -290,25 +291,39 @@ public class ProvidersController : Controller var providerPlans = await _providerPlanRepository.GetByProviderId(id); - if (providerPlans.Count == 0) + switch (provider.Type) { - var newProviderPlans = new List - { - new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }, - new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 } - }; + case ProviderType.Msp: + var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand( + provider.Id, + provider.GatewaySubscriptionId, + [ + (Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum), + (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum) + ]); + await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); + break; + case ProviderType.MultiOrganizationEnterprise: + { + var existingMoePlan = providerPlans.Single(); - foreach (var newProviderPlan in newProviderPlans) - { - await _providerPlanRepository.CreateAsync(newProviderPlan); - } - } - else - { - await _providerBillingService.UpdateSeatMinimums( - provider, - model.EnterpriseMonthlySeatMinimum, - model.TeamsMonthlySeatMinimum); + // 1. Change the plan and take over any old values. + var changeMoePlanCommand = new ChangeProviderPlanCommand( + existingMoePlan.Id, + model.Plan!.Value, + provider.GatewaySubscriptionId); + await _providerBillingService.ChangePlan(changeMoePlanCommand); + + // 2. Update the seat minimums. + var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand( + provider.Id, + provider.GatewaySubscriptionId, + [ + (Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value) + ]); + await _providerBillingService.UpdateSeatMinimums(updateMoeSeatMinimumsCommand); + break; + } } return RedirectToAction("Edit", new { id }); diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index dd9b9f5a5..58221589f 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -33,6 +33,12 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewayCustomerUrl = gatewayCustomerUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; + if (Type == ProviderType.MultiOrganizationEnterprise) + { + var plan = providerPlans.Single(); + EnterpriseMinimumSeats = plan.SeatMinimum; + Plan = plan.PlanType; + } } [Display(Name = "Billing Email")] @@ -58,13 +64,24 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject [Display(Name = "Provider Type")] public ProviderType Type { get; set; } + [Display(Name = "Plan")] + public PlanType? Plan { get; set; } + + [Display(Name = "Enterprise Seats Minimum")] + public int? EnterpriseMinimumSeats { get; set; } + public virtual Provider ToProvider(Provider existingProvider) { existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); - existingProvider.Gateway = Gateway; - existingProvider.GatewayCustomerId = GatewayCustomerId; - existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; + switch (Type) + { + case ProviderType.Msp: + existingProvider.Gateway = Gateway; + existingProvider.GatewayCustomerId = GatewayCustomerId; + existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; + break; + } return existingProvider; } @@ -82,6 +99,23 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); } break; + case ProviderType.MultiOrganizationEnterprise: + if (Plan == null) + { + var displayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); + yield return new ValidationResult($"The {displayName} field is required."); + } + if (EnterpriseMinimumSeats == null) + { + var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMinimumSeats); + yield return new ValidationResult($"The {displayName} field is required."); + } + if (EnterpriseMinimumSeats < 0) + { + var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMinimumSeats); + yield return new ValidationResult($"The {displayName} field cannot be less than 0."); + } + break; } } } diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index 37cda8417..53944d0fc 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -1,6 +1,9 @@ @using Bit.Admin.Enums; @using Bit.Core +@using Bit.Core.AdminConsole.Enums.Provider +@using Bit.Core.Billing.Enums @using Bit.Core.Billing.Extensions +@using Microsoft.AspNetCore.Mvc.TagHelpers @inject Bit.Admin.Services.IAccessControlService AccessControlService @inject Bit.Core.Services.IFeatureService FeatureService @@ -47,60 +50,97 @@ @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable()) { -
-
-
- - -
-
-
-
- - -
-
-
-
-
-
-
- - + switch (Model.Provider.Type) + { + case ProviderType.Msp: + { +
+
+
+ + +
-
-
-
-
-
-
- -
- -
- - - +
+
+ +
-
-
-
- -
- -
- - - +
+
+
+
+ + +
-
-
+
+
+
+ +
+ +
+ + + +
+
+
+
+
+
+ +
+ +
+ + + +
+
+
+
+
+ break; + } + case ProviderType.MultiOrganizationEnterprise: + { + @if (FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) && Model.Provider.Type == ProviderType.MultiOrganizationEnterprise) + { +
+
+
+ @{ + var multiOrgPlans = new List + { + PlanType.EnterpriseAnnually, + PlanType.EnterpriseMonthly + }; + } + + +
+
+
+
+ + +
+
+
+ } + break; + } + } } @await Html.PartialAsync("Organizations", Model) diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index d6fa0988b..1c2c28476 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -13,7 +13,7 @@ public static class BillingExtensions public static bool IsBillable(this Provider provider) => provider is { - Type: ProviderType.Msp, + Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, Status: ProviderStatusType.Billable }; diff --git a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs index ea456e920..ea490d0d6 100644 --- a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Migration.Models; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Services.Contracts; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; @@ -307,7 +308,14 @@ public class ProviderMigrator( .FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)? .SeatMinimum ?? 0; - await providerBillingService.UpdateSeatMinimums(provider, enterpriseSeatMinimum, teamsSeatMinimum); + var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand( + provider.Id, + provider.GatewaySubscriptionId, + [ + (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum), + (Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum) + ]); + await providerBillingService.UpdateSeatMinimums(updateSeatMinimumsCommand); logger.LogInformation( "CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id); diff --git a/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs b/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs new file mode 100644 index 000000000..3e8fffdd1 --- /dev/null +++ b/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Services.Contracts; + +public record ChangeProviderPlanCommand( + Guid ProviderPlanId, + PlanType NewPlan, + string GatewaySubscriptionId); diff --git a/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs b/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs new file mode 100644 index 000000000..86a596ffb --- /dev/null +++ b/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs @@ -0,0 +1,10 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Services.Contracts; + +/// The ID of the provider to update the seat minimums for. +/// The new seat minimums for the provider. +public record UpdateProviderSeatMinimumsCommand( + Guid Id, + string GatewaySubscriptionId, + IReadOnlyCollection<(PlanType Plan, int SeatsMinimum)> Configuration); diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index 2514ca785..e353e5515 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Services.Contracts; using Bit.Core.Models.Business; using Stripe; @@ -89,8 +90,12 @@ public interface IProviderBillingService Task SetupSubscription( Provider provider); - Task UpdateSeatMinimums( - Provider provider, - int enterpriseSeatMinimum, - int teamsSeatMinimum); + /// + /// Changes the assigned provider plan for the provider. + /// + /// The command to change the provider plan. + /// + Task ChangePlan(ChangeProviderPlanCommand command); + + Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command); } diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index a288e1cbe..30583ef0b 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -14,6 +14,17 @@ public interface IStripeAdapter CustomerBalanceTransactionCreateOptions options); Task SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions); Task SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null); + + /// + /// Retrieves a subscription object for a provider. + /// + /// The subscription ID. + /// The provider ID. + /// Additional options. + /// The subscription object. + /// Thrown when the subscription doesn't belong to the provider. + Task ProviderSubscriptionGetAsync(string id, Guid providerId, Stripe.SubscriptionGetOptions options = null); + Task> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions); Task SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null); Task SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index e5fee63b9..8d1833145 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -79,6 +79,20 @@ public class StripeAdapter : IStripeAdapter return _subscriptionService.GetAsync(id, options); } + public async Task ProviderSubscriptionGetAsync( + string id, + Guid providerId, + SubscriptionGetOptions options = null) + { + var subscription = await _subscriptionService.GetAsync(id, options); + if (subscription.Metadata.TryGetValue("providerId", out var value) && value == providerId.ToString()) + { + return subscription; + } + + throw new InvalidOperationException("Subscription does not belong to the provider."); + } + public Task SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null) {