From a26ba3b3309d37ea0bd33fef0f1c77a9f5a98a8c Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 12 Nov 2024 14:11:10 -0500 Subject: [PATCH] [PM-14401] Scale MSP on Admin client organization update (#5001) * Privatize GetAssignedSeatTotalAsync * Add SeatAdjustmentResultsInPurchase method * Move adjustment logic to ProviderClientsController.Update * Remove unused AssignSeatsToClientOrganization method * Alphabetize ProviderBillingService * Scale MSP on Admin client organization update * Run dotnet format * Patch build process * Rui's feedback --------- Co-authored-by: Matt Bishop --- .../Billing/ProviderBillingService.cs | 255 ++-- .../Billing/ProviderBillingServiceTests.cs | 1045 +++++++---------- .../Controllers/OrganizationsController.cs | 89 +- .../Controllers/BaseBillingController.cs | 4 +- .../Controllers/ProviderClientsController.cs | 22 +- .../Services/IProviderBillingService.cs | 50 +- src/Core/Constants.cs | 1 + .../OrganizationsControllerTests.cs | 343 ++++++ .../ProviderClientsControllerTests.cs | 109 +- test/Api.Test/Billing/Utilities.cs | 4 +- 10 files changed, 1047 insertions(+), 875 deletions(-) create mode 100644 test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 7b1d33599..a6bf62871 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -2,17 +2,14 @@ using Bit.Commercial.Core.Billing.Models; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; 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.Billing.Services.Contracts; -using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -27,7 +24,6 @@ using Stripe; namespace Bit.Commercial.Core.Billing; public class ProviderBillingService( - ICurrentContext currentContext, IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, @@ -35,38 +31,76 @@ public class ProviderBillingService( IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, - IProviderRepository providerRepository, IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : IProviderBillingService { - public async Task AssignSeatsToClientOrganization( - Provider provider, - Organization organization, - int seats) + public async Task ChangePlan(ChangeProviderPlanCommand command) { - ArgumentNullException.ThrowIfNull(organization); + var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId); - if (seats < 0) + if (plan == null) { - throw new BillingException( - "You cannot assign negative seats to a client.", - "MSP cannot assign negative seats to a client organization"); + throw new BadRequestException("Provider plan not found."); } - if (seats == organization.Seats) + if (plan.PlanType == command.NewPlan) { - logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats); - return; } - var seatAdjustment = seats - (organization.Seats ?? 0); + var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType); - await ScaleSeats(provider, organization.PlanType, seatAdjustment); + plan.PlanType = command.NewPlan; + await providerPlanRepository.ReplaceAsync(plan); - organization.Seats = seats; + Subscription subscription; + try + { + subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId); + } + catch (InvalidOperationException) + { + throw new ConflictException("Subscription not found."); + } - await organizationRepository.ReplaceAsync(organization); + 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); + + // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) + // 1. Retrieve PlanType and PlanName for ProviderPlan + // 2. Assign PlanType & PlanName to Organization + var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId); + + foreach (var providerOrganization in providerOrganizations) + { + var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + if (organization == null) + { + throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); + } + organization.PlanType = command.NewPlan; + organization.Plan = StaticStore.GetPlan(command.NewPlan).Name; + await organizationRepository.ReplaceAsync(organization); + } } public async Task CreateCustomerForClientOrganization( @@ -171,65 +205,16 @@ public class ProviderBillingService( return memoryStream.ToArray(); } - public async Task GetAssignedSeatTotalForPlanOrThrow( - Guid providerId, - PlanType planType) - { - var provider = await providerRepository.GetByIdAsync(providerId); - - if (provider == null) - { - logger.LogError( - "Could not find provider ({ID}) when retrieving assigned seat total", - providerId); - - throw new BillingException(); - } - - if (provider.Type == ProviderType.Reseller) - { - logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId); - - throw new BillingException(); - } - - var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId); - - var plan = StaticStore.GetPlan(planType); - - return providerOrganizations - .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) - .Sum(providerOrganization => providerOrganization.Seats ?? 0); - } - public async Task ScaleSeats( Provider provider, PlanType planType, int seatAdjustment) { - ArgumentNullException.ThrowIfNull(provider); + var providerPlan = await GetProviderPlanAsync(provider, planType); - if (!provider.SupportsConsolidatedBilling()) - { - logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id); + var seatMinimum = providerPlan.SeatMinimum ?? 0; - throw new BillingException(); - } - - var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); - - var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType); - - if (providerPlan == null || !providerPlan.IsConfigured()) - { - logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType); - - throw new BillingException(); - } - - var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0); - - var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType); + var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType); var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment; @@ -256,13 +241,6 @@ public class ProviderBillingService( else if (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) { - if (!currentContext.ProviderProviderAdmin(provider.Id)) - { - logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id); - - throw new BillingException(); - } - await update( seatMinimum, newlyAssignedSeatTotal); @@ -291,6 +269,26 @@ public class ProviderBillingService( } } + public async Task SeatAdjustmentResultsInPurchase( + Provider provider, + PlanType planType, + int seatAdjustment) + { + var providerPlan = await GetProviderPlanAsync(provider, planType); + + var seatMinimum = providerPlan.SeatMinimum; + + var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType); + + var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment; + + return + // Below the limit to above the limit + (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) || + // Above the limit to further above the limit + (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal); + } + public async Task SetupCustomer( Provider provider, TaxInfo taxInfo) @@ -431,75 +429,6 @@ public class ProviderBillingService( } } - public async Task ChangePlan(ChangeProviderPlanCommand command) - { - var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId); - - 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); - - // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) - // 1. Retrieve PlanType and PlanName for ProviderPlan - // 2. Assign PlanType & PlanName to Organization - var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId); - - foreach (var providerOrganization in providerOrganizations) - { - var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); - if (organization == null) - { - throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); - } - organization.PlanType = command.NewPlan; - organization.Plan = StaticStore.GetPlan(command.NewPlan).Name; - await organizationRepository.ReplaceAsync(organization); - } - } - public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) { if (command.Configuration.Any(x => x.SeatsMinimum < 0)) @@ -610,4 +539,32 @@ public class ProviderBillingService( await providerPlanRepository.ReplaceAsync(providerPlan); }; + + // TODO: Replace with SPROC + private async Task GetAssignedSeatTotalAsync(Provider provider, PlanType planType) + { + var providerOrganizations = + await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id); + + var plan = StaticStore.GetPlan(planType); + + return providerOrganizations + .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) + .Sum(providerOrganization => providerOrganization.Seats ?? 0); + } + + // TODO: Replace with SPROC + private async Task GetProviderPlanAsync(Provider provider, PlanType planType) + { + var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + + var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType); + + if (providerPlan == null || !providerPlan.IsConfigured()) + { + throw new BillingException(message: "Provider plan is missing or misconfigured"); + } + + return providerPlan; + } } diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index 7c3e8cad8..881a98455 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -4,17 +4,14 @@ using Bit.Commercial.Core.Billing; using Bit.Commercial.Core.Billing.Models; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing; using Bit.Core.Billing.Constants; 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; using Bit.Core.Exceptions; @@ -36,460 +33,151 @@ namespace Bit.Commercial.Core.Test.Billing; [SutProviderCustomize] public class ProviderBillingServiceTests { - #region AssignSeatsToClientOrganization & ScaleSeats + #region ChangePlan [Theory, BitAutoData] - public Task AssignSeatsToClientOrganization_NullProvider_ArgumentNullException( - Organization organization, - int seats, - SutProvider sutProvider) - => Assert.ThrowsAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(null, organization, seats)); - - [Theory, BitAutoData] - public Task AssignSeatsToClientOrganization_NullOrganization_ArgumentNullException( - Provider provider, - int seats, - SutProvider sutProvider) - => Assert.ThrowsAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, null, seats)); - - [Theory, BitAutoData] - public Task AssignSeatsToClientOrganization_NegativeSeats_BillingException( - Provider provider, - Organization organization, - SutProvider sutProvider) - => Assert.ThrowsAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, -5)); - - [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_CurrentSeatsMatchesNewSeats_NoOp( - Provider provider, - Organization organization, - int seats, + public async Task ChangePlan_NullProviderPlan_ThrowsBadRequestException( + ChangeProviderPlanCommand command, SutProvider sutProvider) { - organization.PlanType = PlanType.TeamsMonthly; + // Arrange + var providerPlanRepository = sutProvider.GetDependency(); + providerPlanRepository.GetByIdAsync(Arg.Any()).Returns((ProviderPlan)null); - organization.Seats = seats; + // Act + var actual = await Assert.ThrowsAsync(() => sutProvider.Sut.ChangePlan(command)); - await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); - - await sutProvider.GetDependency().DidNotReceive().GetByProviderId(provider.Id); + // Assert + Assert.Equal("Provider plan not found.", actual.Message); } [Theory, BitAutoData] - public async Task - AssignSeatsToClientOrganization_OrganizationPlanTypeDoesNotSupportConsolidatedBilling_ContactSupport( - Provider provider, - Organization organization, - int seats, - SutProvider sutProvider) - { - organization.PlanType = PlanType.FamiliesAnnually; - - await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); - } - - [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_ProviderPlanIsNotConfigured_ContactSupport( - Provider provider, - Organization organization, - int seats, + public async Task ChangePlan_ProviderNotFound_DoesNothing( + ChangeProviderPlanCommand command, SutProvider sutProvider) { - organization.PlanType = PlanType.TeamsMonthly; - - sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(new List + // Arrange + var providerPlanRepository = sutProvider.GetDependency(); + var stripeAdapter = sutProvider.GetDependency(); + var existingPlan = new ProviderPlan { - new() { Id = Guid.NewGuid(), PlanType = PlanType.TeamsMonthly, ProviderId = provider.Id } - }); - - await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); - } - - [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_BelowToBelow_Succeeds( - Provider provider, - Organization organization, - SutProvider sutProvider) - { - organization.Seats = 10; - - organization.PlanType = PlanType.TeamsMonthly; - - // Scale up 10 seats - const int seats = 20; - - var providerPlans = new List - { - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.TeamsMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - // 100 minimum - SeatMinimum = 100, - AllocatedSeats = 50 - }, - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.EnterpriseMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - SeatMinimum = 500, - AllocatedSeats = 0 - } + Id = command.ProviderPlanId, + PlanType = command.NewPlan, + PurchasedSeats = 0, + AllocatedSeats = 0, + SeatMinimum = 0 }; + providerPlanRepository + .GetByIdAsync(Arg.Is(p => p == command.ProviderPlanId)) + .Returns(existingPlan); - var providerPlan = providerPlans.First(); + // Act + await sutProvider.Sut.ChangePlan(command); - sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); - - // 50 seats currently assigned with a seat minimum of 100 - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - - sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( - [ - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 25 - }, - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 25 - } - ]); - - await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); - - // 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().AdjustSeats( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - org => org.Id == organization.Id && org.Seats == seats)); - - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - pPlan => pPlan.AllocatedSeats == 60)); + // Assert + await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any()); + await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_BelowToAbove_NotProviderAdmin_ContactSupport( - Provider provider, - Organization organization, + public async Task ChangePlan_SameProviderPlan_DoesNothing( + ChangeProviderPlanCommand command, SutProvider sutProvider) { - organization.Seats = 10; - - organization.PlanType = PlanType.TeamsMonthly; - - // Scale up 10 seats - const int seats = 20; - - var providerPlans = new List + // Arrange + var providerPlanRepository = sutProvider.GetDependency(); + var stripeAdapter = sutProvider.GetDependency(); + var existingPlan = new ProviderPlan { - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.TeamsMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - // 100 minimum - SeatMinimum = 100, - AllocatedSeats = 95 - }, - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.EnterpriseMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - SeatMinimum = 500, - AllocatedSeats = 0 - } + Id = command.ProviderPlanId, + PlanType = command.NewPlan, + PurchasedSeats = 0, + AllocatedSeats = 0, + SeatMinimum = 0 }; + providerPlanRepository + .GetByIdAsync(Arg.Is(p => p == command.ProviderPlanId)) + .Returns(existingPlan); - sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + // Act + await sutProvider.Sut.ChangePlan(command); - // 95 seats currently assigned with a seat minimum of 100 - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - - sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( - [ - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 60 - }, - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 35 - } - ]); - - sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); - - await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); + // Assert + await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any()); + await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_BelowToAbove_Succeeds( + public async Task ChangePlan_UpdatesSubscriptionCorrectly( + Guid providerPlanId, Provider provider, - Organization organization, SutProvider sutProvider) { - organization.Seats = 10; - - organization.PlanType = PlanType.TeamsMonthly; - - // Scale up 10 seats - const int seats = 20; - - var providerPlans = new List + // Arrange + var providerPlanRepository = sutProvider.GetDependency(); + var existingPlan = new ProviderPlan { - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.TeamsMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - // 100 minimum - SeatMinimum = 100, - AllocatedSeats = 95 - }, - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.EnterpriseMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - SeatMinimum = 500, - AllocatedSeats = 0 - } + Id = providerPlanId, + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseAnnually, + PurchasedSeats = 2, + AllocatedSeats = 10, + SeatMinimum = 8 }; + providerPlanRepository + .GetByIdAsync(Arg.Is(p => p == providerPlanId)) + .Returns(existingPlan); - var providerPlan = providerPlans.First(); - - sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); - - // 95 seats currently assigned with a seat minimum of 100 - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - - sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( - [ - new ProviderOrganizationOrganizationDetails + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.ProviderSubscriptionGetAsync( + Arg.Is(provider.GatewaySubscriptionId), + Arg.Is(provider.Id)) + .Returns(new Subscription { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 60 - }, - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 35 - } - ]); + 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 + } + ] + } + }); - sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + var command = + new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId); - await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); + // Act + await sutProvider.Sut.ChangePlan(command); - // 95 current + 10 seat scale = 105 seats, 5 above the minimum - await sutProvider.GetDependency().Received(1).AdjustSeats( - provider, - StaticStore.GetPlan(providerPlan.PlanType), - providerPlan.SeatMinimum!.Value, - 105); + // Assert + await providerPlanRepository.Received(1) + .ReplaceAsync(Arg.Is(p => p.PlanType == PlanType.EnterpriseMonthly)); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - org => org.Id == organization.Id && org.Seats == seats)); + 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)); - // 105 total seats - 100 minimum = 5 purchased seats - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 5 && pPlan.AllocatedSeats == 105)); - } - - [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_AboveToAbove_Succeeds( - Provider provider, - Organization organization, - SutProvider sutProvider) - { - provider.Type = ProviderType.Msp; - - organization.Seats = 10; - - organization.PlanType = PlanType.TeamsMonthly; - - // Scale up 10 seats - const int seats = 20; - - var providerPlans = new List - { - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.TeamsMonthly, - ProviderId = provider.Id, - // 10 additional purchased seats - PurchasedSeats = 10, - // 100 seat minimum - SeatMinimum = 100, - AllocatedSeats = 110 - }, - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.EnterpriseMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - SeatMinimum = 500, - AllocatedSeats = 0 - } - }; - - var providerPlan = providerPlans.First(); - - sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); - - // 110 seats currently assigned with a seat minimum of 100 - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - - sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( - [ - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 60 - }, - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 50 - } - ]); - - await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); - - // 110 current + 10 seat scale up = 120 seats - await sutProvider.GetDependency().Received(1).AdjustSeats( - provider, - StaticStore.GetPlan(providerPlan.PlanType), - 110, - 120); - - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - org => org.Id == organization.Id && org.Seats == seats)); - - // 120 total seats - 100 seat minimum = 20 purchased seats - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 20 && pPlan.AllocatedSeats == 120)); - } - - [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_AboveToBelow_Succeeds( - Provider provider, - Organization organization, - SutProvider sutProvider) - { - organization.Seats = 50; - - organization.PlanType = PlanType.TeamsMonthly; - - // Scale down 30 seats - const int seats = 20; - - var providerPlans = new List - { - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.TeamsMonthly, - ProviderId = provider.Id, - // 10 additional purchased seats - PurchasedSeats = 10, - // 100 seat minimum - SeatMinimum = 100, - AllocatedSeats = 110 - }, - new() - { - Id = Guid.NewGuid(), - PlanType = PlanType.EnterpriseMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - SeatMinimum = 500, - AllocatedSeats = 0 - } - }; - - var providerPlan = providerPlans.First(); - - sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); - - // 110 seats currently assigned with a seat minimum of 100 - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - - sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( - [ - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 60 - }, - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 50 - } - ]); - - await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); - - // 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum. - await sutProvider.GetDependency().Received(1).AdjustSeats( - provider, - StaticStore.GetPlan(providerPlan.PlanType), - 110, - providerPlan.SeatMinimum!.Value); - - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - org => org.Id == organization.Id && org.Seats == seats)); - - // Being below the seat minimum means no purchased seats. - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 0 && pPlan.AllocatedSeats == 80)); + 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 @@ -673,66 +361,335 @@ public class ProviderBillingServiceTests #endregion - #region GetAssignedSeatTotalForPlanOrThrow + #region ScaleSeats [Theory, BitAutoData] - public async Task GetAssignedSeatTotalForPlanOrThrow_NullProvider_ContactSupport( - Guid providerId, - SutProvider sutProvider) - => await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly)); - - [Theory, BitAutoData] - public async Task GetAssignedSeatTotalForPlanOrThrow_ResellerProvider_ContactSupport( - Guid providerId, + public async Task ScaleSeats_BelowToBelow_Succeeds( Provider provider, SutProvider sutProvider) { - provider.Type = ProviderType.Reseller; - - sutProvider.GetDependency().GetByIdAsync(providerId).Returns(provider); - - await ThrowsBillingExceptionAsync( - () => sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly)); - } - - [Theory, BitAutoData] - public async Task GetAssignedSeatTotalForPlanOrThrow_Succeeds( - Guid providerId, - Provider provider, - SutProvider sutProvider) - { - provider.Type = ProviderType.Msp; - - sutProvider.GetDependency().GetByIdAsync(providerId).Returns(provider); - - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - var enterpriseMonthlyPlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); - - var providerOrganizationOrganizationDetailList = new List + var providerPlans = new List { - new() { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 10 }, - new() { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 10 }, new() { - // Ignored because of status. - Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Created, Seats = 100 + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + ProviderId = provider.Id, + PurchasedSeats = 0, + SeatMinimum = 100, + AllocatedSeats = 50 }, new() { - // Ignored because of plan. - Plan = enterpriseMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 30 + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseMonthly, + ProviderId = provider.Id, + PurchasedSeats = 0, + SeatMinimum = 500, + AllocatedSeats = 0 } }; - sutProvider.GetDependency() - .GetManyDetailsByProviderAsync(providerId) - .Returns(providerOrganizationOrganizationDetailList); + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); - var assignedSeatTotal = - await sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly); + // 50 seats currently assigned with a seat minimum of 100 + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - Assert.Equal(20, assignedSeatTotal); + sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( + [ + new ProviderOrganizationOrganizationDetails + { + Plan = teamsMonthlyPlan.Name, + Status = OrganizationStatusType.Managed, + Seats = 25 + }, + new ProviderOrganizationOrganizationDetails + { + Plan = teamsMonthlyPlan.Name, + Status = OrganizationStatusType.Managed, + Seats = 25 + } + ]); + + await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); + + // 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().AdjustSeats( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( + pPlan => pPlan.AllocatedSeats == 60)); + } + + [Theory, BitAutoData] + public async Task ScaleSeats_BelowToAbove_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var providerPlans = new List + { + new() + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + ProviderId = provider.Id, + PurchasedSeats = 0, + SeatMinimum = 100, + AllocatedSeats = 95 + }, + new() + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseMonthly, + ProviderId = provider.Id, + PurchasedSeats = 0, + SeatMinimum = 500, + AllocatedSeats = 0 + } + }; + + var providerPlan = providerPlans.First(); + + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + + // 95 seats currently assigned with a seat minimum of 100 + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( + [ + new ProviderOrganizationOrganizationDetails + { + Plan = teamsMonthlyPlan.Name, + Status = OrganizationStatusType.Managed, + Seats = 60 + }, + new ProviderOrganizationOrganizationDetails + { + Plan = teamsMonthlyPlan.Name, + Status = OrganizationStatusType.Managed, + Seats = 35 + } + ]); + + await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); + + // 95 current + 10 seat scale = 105 seats, 5 above the minimum + await sutProvider.GetDependency().Received(1).AdjustSeats( + provider, + StaticStore.GetPlan(providerPlan.PlanType), + providerPlan.SeatMinimum!.Value, + 105); + + // 105 total seats - 100 minimum = 5 purchased seats + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( + pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 5 && pPlan.AllocatedSeats == 105)); + } + + [Theory, BitAutoData] + public async Task ScaleSeats_AboveToAbove_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var providerPlans = new List + { + new() + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + ProviderId = provider.Id, + PurchasedSeats = 10, + SeatMinimum = 100, + AllocatedSeats = 110 + }, + new() + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseMonthly, + ProviderId = provider.Id, + PurchasedSeats = 0, + SeatMinimum = 500, + AllocatedSeats = 0 + } + }; + + var providerPlan = providerPlans.First(); + + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + + // 110 seats currently assigned with a seat minimum of 100 + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( + [ + new ProviderOrganizationOrganizationDetails + { + Plan = teamsMonthlyPlan.Name, + Status = OrganizationStatusType.Managed, + Seats = 60 + }, + new ProviderOrganizationOrganizationDetails + { + Plan = teamsMonthlyPlan.Name, + Status = OrganizationStatusType.Managed, + Seats = 50 + } + ]); + + await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); + + // 110 current + 10 seat scale up = 120 seats + await sutProvider.GetDependency().Received(1).AdjustSeats( + provider, + StaticStore.GetPlan(providerPlan.PlanType), + 110, + 120); + + // 120 total seats - 100 seat minimum = 20 purchased seats + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( + pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 20 && pPlan.AllocatedSeats == 120)); + } + + [Theory, BitAutoData] + public async Task ScaleSeats_AboveToBelow_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var providerPlans = new List + { + new() + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + ProviderId = provider.Id, + PurchasedSeats = 10, + SeatMinimum = 100, + AllocatedSeats = 110 + }, + new() + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseMonthly, + ProviderId = provider.Id, + PurchasedSeats = 0, + SeatMinimum = 500, + AllocatedSeats = 0 + } + }; + + var providerPlan = providerPlans.First(); + + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + + // 110 seats currently assigned with a seat minimum of 100 + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( + [ + new ProviderOrganizationOrganizationDetails + { + Plan = teamsMonthlyPlan.Name, + Status = OrganizationStatusType.Managed, + Seats = 60 + }, + new ProviderOrganizationOrganizationDetails + { + Plan = teamsMonthlyPlan.Name, + Status = OrganizationStatusType.Managed, + Seats = 50 + } + ]); + + await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30); + + // 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum. + await sutProvider.GetDependency().Received(1).AdjustSeats( + provider, + StaticStore.GetPlan(providerPlan.PlanType), + 110, + providerPlan.SeatMinimum!.Value); + + // Being below the seat minimum means no purchased seats. + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( + pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 0 && pPlan.AllocatedSeats == 80)); + } + + #endregion + + #region SeatAdjustmentResultsInPurchase + + [Theory, BitAutoData] + public async Task SeatAdjustmentResultsInPurchase_BelowToAbove_True( + Provider provider, + PlanType planType, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns([ + new ProviderPlan + { + PlanType = planType, + SeatMinimum = 10, + AllocatedSeats = 0, + PurchasedSeats = 0 + } + ]); + + sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( + [ + new ProviderOrganizationOrganizationDetails + { + Plan = StaticStore.GetPlan(planType).Name, + Status = OrganizationStatusType.Managed, + Seats = 5 + } + ]); + + const int seatAdjustment = 10; + + var result = await sutProvider.Sut.SeatAdjustmentResultsInPurchase( + provider, + planType, + seatAdjustment); + + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task SeatAdjustmentResultsInPurchase_AboveToFurtherAbove_True( + Provider provider, + PlanType planType, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns([ + new ProviderPlan + { + PlanType = planType, + SeatMinimum = 10, + AllocatedSeats = 0, + PurchasedSeats = 5 + } + ]); + + sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( + [ + new ProviderOrganizationOrganizationDetails + { + Plan = StaticStore.GetPlan(planType).Name, + Status = OrganizationStatusType.Managed, + Seats = 15 + } + ]); + + const int seatAdjustment = 5; + + var result = await sutProvider.Sut.SeatAdjustmentResultsInPurchase( + provider, + planType, + seatAdjustment); + + Assert.True(result); } #endregion @@ -1012,158 +969,6 @@ public class ProviderBillingServiceTests #endregion - #region ChangePlan - - [Theory, BitAutoData] - 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] @@ -1172,8 +977,6 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange - var providerRepository = sutProvider.GetDependency(); - providerRepository.GetByIdAsync(provider.Id).Returns(provider); var command = new UpdateProviderSeatMinimumsCommand( provider.Id, provider.GatewaySubscriptionId, @@ -1197,7 +1000,6 @@ public class ProviderBillingServiceTests // 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"; @@ -1235,7 +1037,6 @@ 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); var command = new UpdateProviderSeatMinimumsCommand( @@ -1274,8 +1075,6 @@ public class ProviderBillingServiceTests // 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"; @@ -1349,8 +1148,6 @@ public class ProviderBillingServiceTests // 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"; @@ -1418,8 +1215,6 @@ public class ProviderBillingServiceTests // 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"; @@ -1493,8 +1288,6 @@ public class ProviderBillingServiceTests // 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"; diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index efab8620c..db41e9282 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -5,8 +5,10 @@ using Bit.Admin.Services; using Bit.Admin.Utilities; using Bit.Core; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -230,7 +232,23 @@ public class OrganizationsController : Controller [SelfHosted(NotSelfHostedOnly = true)] public async Task Edit(Guid id, OrganizationEditModel model) { - var organization = await GetOrganization(id, model); + var organization = await _organizationRepository.GetByIdAsync(id); + + if (organization == null) + { + TempData["Error"] = "Could not find organization to update."; + return RedirectToAction("Index"); + } + + var existingOrganizationData = new Organization + { + Id = organization.Id, + Status = organization.Status, + PlanType = organization.PlanType, + Seats = organization.Seats + }; + + UpdateOrganization(organization, model); if (organization.UseSecretsManager && !StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager) @@ -239,7 +257,12 @@ public class OrganizationsController : Controller return RedirectToAction("Edit", new { id }); } + await HandlePotentialProviderSeatScalingAsync( + existingOrganizationData, + model); + await _organizationRepository.ReplaceAsync(organization); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext) { @@ -394,10 +417,9 @@ public class OrganizationsController : Controller return Json(null); } - private async Task GetOrganization(Guid id, OrganizationEditModel model) - { - var organization = await _organizationRepository.GetByIdAsync(id); + private void UpdateOrganization(Organization organization, OrganizationEditModel model) + { if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox)) { organization.Enabled = model.Enabled; @@ -449,7 +471,64 @@ public class OrganizationsController : Controller organization.GatewayCustomerId = model.GatewayCustomerId; organization.GatewaySubscriptionId = model.GatewaySubscriptionId; } + } - return organization; + private async Task HandlePotentialProviderSeatScalingAsync( + Organization organization, + OrganizationEditModel update) + { + var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); + + var scaleMSPOnClientOrganizationUpdate = + _featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate); + + if (!consolidatedBillingEnabled || !scaleMSPOnClientOrganizationUpdate) + { + return; + } + + var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id); + + // No scaling required + if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } || + organization is not { Status: OrganizationStatusType.Managed } || + !organization.Seats.HasValue || + update is { Seats: null, PlanType: null } || + update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } || + (PlanTypesMatch() && SeatsMatch())) + { + return; + } + + // Only scale the plan + if (!PlanTypesMatch() && SeatsMatch()) + { + await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value); + await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value); + } + // Only scale the seats + else if (PlanTypesMatch() && !SeatsMatch()) + { + var seatAdjustment = update.Seats!.Value - organization.Seats.Value; + await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment); + } + // Scale both + else if (!PlanTypesMatch() && !SeatsMatch()) + { + var seatAdjustment = update.Seats!.Value - organization.Seats.Value; + var planTypeAdjustment = organization.Seats.Value; + var totalAdjustment = seatAdjustment + planTypeAdjustment; + + await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value); + await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment); + } + + return; + + bool PlanTypesMatch() + => update.PlanType.HasValue && update.PlanType.Value == organization.PlanType; + + bool SeatsMatch() + => update.Seats.HasValue && update.Seats.Value == organization.Seats; } } diff --git a/src/Api/Billing/Controllers/BaseBillingController.cs b/src/Api/Billing/Controllers/BaseBillingController.cs index 81b8b29f2..5f7005fdf 100644 --- a/src/Api/Billing/Controllers/BaseBillingController.cs +++ b/src/Api/Billing/Controllers/BaseBillingController.cs @@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller new ErrorResponseModel(message), statusCode: StatusCodes.Status500InternalServerError); - public static JsonHttpResult Unauthorized() => + public static JsonHttpResult Unauthorized(string message = "Unauthorized.") => TypedResults.Json( - new ErrorResponseModel("Unauthorized."), + new ErrorResponseModel(message), statusCode: StatusCodes.Status401Unauthorized); } } diff --git a/src/Api/Billing/Controllers/ProviderClientsController.cs b/src/Api/Billing/Controllers/ProviderClientsController.cs index 23a6da459..700dd4a2e 100644 --- a/src/Api/Billing/Controllers/ProviderClientsController.cs +++ b/src/Api/Billing/Controllers/ProviderClientsController.cs @@ -102,15 +102,27 @@ public class ProviderClientsController( var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); - if (clientOrganization.Seats != requestBody.AssignedSeats) + if (clientOrganization is not { Status: OrganizationStatusType.Managed }) { - await providerBillingService.AssignSeatsToClientOrganization( - provider, - clientOrganization, - requestBody.AssignedSeats); + return Error.ServerError(); } + var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0); + + var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase( + provider, + clientOrganization.PlanType, + seatAdjustment); + + if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id)) + { + return Error.Unauthorized("Service users cannot purchase additional seats."); + } + + await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment); + clientOrganization.Name = requestBody.Name; + clientOrganization.Seats = requestBody.AssignedSeats; await organizationRepository.ReplaceAsync(clientOrganization); diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index e353e5515..20e740762 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -1,6 +1,5 @@ using Bit.Core.AdminConsole.Entities; 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; @@ -12,18 +11,10 @@ namespace Bit.Core.Billing.Services; public interface IProviderBillingService { /// - /// Assigns a specified number of to a client on behalf of - /// its . Seat adjustments for the client organization may autoscale the provider's Stripe - /// depending on the provider's seat minimum for the client 's - /// . + /// Changes the assigned provider plan for the provider. /// - /// The that manages the client . - /// The client whose you want to update. - /// The number of seats to assign to the client organization. - Task AssignSeatsToClientOrganization( - Provider provider, - Organization organization, - int seats); + /// The command to change the provider plan. + Task ChangePlan(ChangeProviderPlanCommand command); /// /// Create a Stripe for the provided client utilizing @@ -44,18 +35,6 @@ public interface IProviderBillingService Task GenerateClientInvoiceReport( string invoiceId); - /// - /// Retrieves the number of seats an MSP has assigned to its client organizations with a specified . - /// - /// The ID of the MSP to retrieve the assigned seat total for. - /// The type of plan to retrieve the assigned seat total for. - /// An representing the number of seats the provider has assigned to its client organizations with the specified . - /// Thrown when the provider represented by the is . - /// Thrown when the provider represented by the has . - Task GetAssignedSeatTotalForPlanOrThrow( - Guid providerId, - PlanType planType); - /// /// Scales the 's seats for the specified using the provided . /// This operation may autoscale the provider's Stripe depending on the 's seat minimum for the @@ -69,6 +48,22 @@ public interface IProviderBillingService PlanType planType, int seatAdjustment); + /// + /// Determines whether the provided will result in a purchase for the 's . + /// Seat adjustments that result in purchases include: + /// + /// The going from below the seat minimum to above the seat minimum for the provided + /// The going from above the seat minimum to further above the seat minimum for the provided + /// + /// + /// The provider to check seat adjustments for. + /// The plan type to check seat adjustments for. + /// The change in seats for the 's . + Task SeatAdjustmentResultsInPurchase( + Provider provider, + PlanType planType, + int seatAdjustment); + /// /// For use during the provider setup process, this method creates a Stripe for the specified utilizing the provided . /// @@ -90,12 +85,5 @@ public interface IProviderBillingService Task SetupSubscription( Provider provider); - /// - /// 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/Constants.cs b/src/Core/Constants.cs index 67d463bac..5830b480e 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -156,6 +156,7 @@ public static class FeatureFlagKeys public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string SecurityTasks = "security-tasks"; + public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update"; public static List GetAllKeys() { diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs new file mode 100644 index 000000000..af98fef03 --- /dev/null +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -0,0 +1,343 @@ +using Bit.Admin.AdminConsole.Controllers; +using Bit.Admin.AdminConsole.Models; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Services; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; + +namespace Admin.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(OrganizationsController))] +[SutProviderCustomize] +public class OrganizationsControllerTests +{ + #region Edit (POST) + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_ProviderSeatScaling_RequiredFFDisabled_NoOp( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + var update = new OrganizationEditModel { UseSecretsManager = false }; + + var organization = new Organization + { + Id = organizationId + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ScaleSeats(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_ProviderSeatScaling_NonBillableProvider_NoOp( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + var update = new OrganizationEditModel { UseSecretsManager = false }; + + var organization = new Organization + { + Id = organizationId + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + var featureService = sutProvider.GetDependency(); + + featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true); + + var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Created }; + + sutProvider.GetDependency().GetByOrganizationIdAsync(organizationId).Returns(provider); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ScaleSeats(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_ProviderSeatScaling_UnmanagedOrganization_NoOp( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + var update = new OrganizationEditModel { UseSecretsManager = false }; + + var organization = new Organization + { + Id = organizationId, + Status = OrganizationStatusType.Created + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + var featureService = sutProvider.GetDependency(); + + featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true); + + var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable }; + + sutProvider.GetDependency().GetByOrganizationIdAsync(organizationId).Returns(provider); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ScaleSeats(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_ProviderSeatScaling_NonCBPlanType_NoOp( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + + var update = new OrganizationEditModel + { + UseSecretsManager = false, + Seats = 10, + PlanType = PlanType.FamiliesAnnually + }; + + var organization = new Organization + { + Id = organizationId, + Status = OrganizationStatusType.Managed, + Seats = 10 + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + var featureService = sutProvider.GetDependency(); + + featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true); + + var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable }; + + sutProvider.GetDependency().GetByOrganizationIdAsync(organizationId).Returns(provider); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ScaleSeats(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_ProviderSeatScaling_NoUpdateRequired_NoOp( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + var update = new OrganizationEditModel + { + UseSecretsManager = false, + Seats = 10, + PlanType = PlanType.EnterpriseMonthly + }; + + var organization = new Organization + { + Id = organizationId, + Status = OrganizationStatusType.Managed, + Seats = 10, + PlanType = PlanType.EnterpriseMonthly + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + var featureService = sutProvider.GetDependency(); + + featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true); + + var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable }; + + sutProvider.GetDependency().GetByOrganizationIdAsync(organizationId).Returns(provider); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ScaleSeats(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_ProviderSeatScaling_PlanTypesUpdate_ScalesSeatsCorrectly( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + var update = new OrganizationEditModel + { + UseSecretsManager = false, + Seats = 10, + PlanType = PlanType.EnterpriseMonthly + }; + + var organization = new Organization + { + Id = organizationId, + Status = OrganizationStatusType.Managed, + Seats = 10, + PlanType = PlanType.TeamsMonthly + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + var featureService = sutProvider.GetDependency(); + + featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true); + + var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable }; + + sutProvider.GetDependency().GetByOrganizationIdAsync(organizationId).Returns(provider); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + var providerBillingService = sutProvider.GetDependency(); + + await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value); + await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_ProviderSeatScaling_SeatsUpdate_ScalesSeatsCorrectly( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + var update = new OrganizationEditModel + { + UseSecretsManager = false, + Seats = 15, + PlanType = PlanType.EnterpriseMonthly + }; + + var organization = new Organization + { + Id = organizationId, + Status = OrganizationStatusType.Managed, + Seats = 10, + PlanType = PlanType.EnterpriseMonthly + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + var featureService = sutProvider.GetDependency(); + + featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true); + + var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable }; + + sutProvider.GetDependency().GetByOrganizationIdAsync(organizationId).Returns(provider); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + var providerBillingService = sutProvider.GetDependency(); + + await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, update.Seats!.Value - organization.Seats.Value); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_ProviderSeatScaling_FullUpdate_ScalesSeatsCorrectly( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + var update = new OrganizationEditModel + { + UseSecretsManager = false, + Seats = 15, + PlanType = PlanType.EnterpriseMonthly + }; + + var organization = new Organization + { + Id = organizationId, + Status = OrganizationStatusType.Managed, + Seats = 10, + PlanType = PlanType.TeamsMonthly + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + var featureService = sutProvider.GetDependency(); + + featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true); + + var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable }; + + sutProvider.GetDependency().GetByOrganizationIdAsync(organizationId).Returns(provider); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + var providerBillingService = sutProvider.GetDependency(); + + await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value); + await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, update.Seats!.Value - organization.Seats.Value + organization.Seats.Value); + } + + #endregion +} diff --git a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs index 450ff9bf2..86bacd9aa 100644 --- a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs @@ -5,8 +5,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Services; +using Bit.Core.Context; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; @@ -93,24 +96,7 @@ public class ProviderClientsControllerTests #region UpdateAsync [Theory, BitAutoData] - public async Task UpdateAsync_NoProviderOrganization_NotFound( - Provider provider, - Guid providerOrganizationId, - UpdateClientOrganizationRequestBody requestBody, - SutProvider sutProvider) - { - ConfigureStableProviderServiceUserInputs(provider, sutProvider); - - sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) - .ReturnsNull(); - - var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody); - - AssertNotFound(result); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_AssignedSeats_Ok( + public async Task UpdateAsync_ServiceUserMakingPurchase_Unauthorized( Provider provider, Guid providerOrganizationId, UpdateClientOrganizationRequestBody requestBody, @@ -118,6 +104,11 @@ public class ProviderClientsControllerTests Organization organization, SutProvider sutProvider) { + organization.PlanType = PlanType.TeamsMonthly; + organization.Seats = 10; + organization.Status = OrganizationStatusType.Managed; + requestBody.AssignedSeats = 20; + ConfigureStableProviderServiceUserInputs(provider, sutProvider); sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) @@ -126,49 +117,57 @@ public class ProviderClientsControllerTests sutProvider.GetDependency().GetByIdAsync(providerOrganization.OrganizationId) .Returns(organization); + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); + + sutProvider.GetDependency().SeatAdjustmentResultsInPurchase( + provider, + PlanType.TeamsMonthly, + 10).Returns(true); + + var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody); + + AssertUnauthorized(result, message: "Service users cannot purchase additional seats."); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_Ok( + Provider provider, + Guid providerOrganizationId, + UpdateClientOrganizationRequestBody requestBody, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsMonthly; + organization.Seats = 10; + organization.Status = OrganizationStatusType.Managed; + requestBody.AssignedSeats = 20; + + ConfigureStableProviderServiceUserInputs(provider, sutProvider); + + sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) + .Returns(providerOrganization); + + sutProvider.GetDependency().GetByIdAsync(providerOrganization.OrganizationId) + .Returns(organization); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); + + sutProvider.GetDependency().SeatAdjustmentResultsInPurchase( + provider, + PlanType.TeamsMonthly, + 10).Returns(false); + var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody); await sutProvider.GetDependency().Received(1) - .AssignSeatsToClientOrganization( + .ScaleSeats( provider, - organization, - requestBody.AssignedSeats); + PlanType.TeamsMonthly, + 10); await sutProvider.GetDependency().Received(1) - .ReplaceAsync(Arg.Is(org => org.Name == requestBody.Name)); - - Assert.IsType(result); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_Name_Ok( - Provider provider, - Guid providerOrganizationId, - UpdateClientOrganizationRequestBody requestBody, - ProviderOrganization providerOrganization, - Organization organization, - SutProvider sutProvider) - { - ConfigureStableProviderServiceUserInputs(provider, sutProvider); - - sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) - .Returns(providerOrganization); - - sutProvider.GetDependency().GetByIdAsync(providerOrganization.OrganizationId) - .Returns(organization); - - requestBody.AssignedSeats = organization.Seats!.Value; - - var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .AssignSeatsToClientOrganization( - Arg.Any(), - Arg.Any(), - Arg.Any()); - - await sutProvider.GetDependency().Received(1) - .ReplaceAsync(Arg.Is(org => org.Name == requestBody.Name)); + .ReplaceAsync(Arg.Is(org => org.Seats == requestBody.AssignedSeats && org.Name == requestBody.Name)); Assert.IsType(result); } diff --git a/test/Api.Test/Billing/Utilities.cs b/test/Api.Test/Billing/Utilities.cs index 36291ec71..3f5eee72c 100644 --- a/test/Api.Test/Billing/Utilities.cs +++ b/test/Api.Test/Billing/Utilities.cs @@ -25,14 +25,14 @@ public static class Utilities Assert.Equal("Resource not found.", response.Message); } - public static void AssertUnauthorized(IResult result) + public static void AssertUnauthorized(IResult result, string message = "Unauthorized.") { Assert.IsType>(result); var response = (JsonHttpResult)result; Assert.Equal(StatusCodes.Status401Unauthorized, response.StatusCode); - Assert.Equal("Unauthorized.", response.Value.Message); + Assert.Equal(message, response.Value.Message); } public static void ConfigureStableProviderAdminInputs(