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 | 993 +++++++----------- .../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 | 69 +- test/Api.Test/Billing/Utilities.cs | 4 +- 10 files changed, 1001 insertions(+), 829 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 7b1d33599e59..a6bf62871f29 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 @@ 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 async Task GenerateClientInvoiceReport( 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 async Task ScaleSeats( 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 @@ await update( } } + 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 async Task SetupSubscription( } } - 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 @@ await paymentService.AdjustSeats( 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 7c3e8cad8764..881a9845545b 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.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,170 +33,341 @@ 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, + public async Task ChangePlan_NullProviderPlan_ThrowsBadRequestException( + ChangeProviderPlanCommand command, SutProvider sutProvider) - => Assert.ThrowsAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(null, organization, seats)); + { + // 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 Task AssignSeatsToClientOrganization_NullOrganization_ArgumentNullException( - Provider provider, - int seats, + public async Task ChangePlan_ProviderNotFound_DoesNothing( + ChangeProviderPlanCommand command, SutProvider sutProvider) - => Assert.ThrowsAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, null, seats)); + { + // 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 Task AssignSeatsToClientOrganization_NegativeSeats_BillingException( - Provider provider, - Organization organization, + public async Task ChangePlan_SameProviderPlan_DoesNothing( + ChangeProviderPlanCommand command, SutProvider sutProvider) - => Assert.ThrowsAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, -5)); + { + // 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 AssignSeatsToClientOrganization_CurrentSeatsMatchesNewSeats_NoOp( + public async Task ChangePlan_UpdatesSubscriptionCorrectly( + Guid providerPlanId, Provider provider, - Organization organization, - int seats, SutProvider sutProvider) { - organization.PlanType = PlanType.TeamsMonthly; + // 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); - organization.Seats = seats; + 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 sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, 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)); - await sutProvider.GetDependency().DidNotReceive().GetByProviderId(provider.Id); + 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 CreateCustomerForClientOrganization + [Theory, BitAutoData] - public async Task - AssignSeatsToClientOrganization_OrganizationPlanTypeDoesNotSupportConsolidatedBilling_ContactSupport( - Provider provider, - Organization organization, - int seats, - SutProvider sutProvider) - { - organization.PlanType = PlanType.FamiliesAnnually; + public async Task CreateCustomerForClientOrganization_ProviderNull_ThrowsArgumentNullException( + Organization organization, + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateCustomerForClientOrganization(null, organization)); - await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); - } + [Theory, BitAutoData] + public async Task CreateCustomerForClientOrganization_OrganizationNull_ThrowsArgumentNullException( + Provider provider, + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateCustomerForClientOrganization(provider, null)); [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_ProviderPlanIsNotConfigured_ContactSupport( + public async Task CreateCustomerForClientOrganization_HasGatewayCustomerId_NoOp( Provider provider, Organization organization, - int seats, SutProvider sutProvider) { - organization.PlanType = PlanType.TeamsMonthly; + organization.GatewayCustomerId = "customer_id"; - sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(new List - { - new() { Id = Guid.NewGuid(), PlanType = PlanType.TeamsMonthly, ProviderId = provider.Id } - }); + await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); - await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetCustomerOrThrow(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_BelowToBelow_Succeeds( + public async Task CreateCustomer_ForClientOrg_Succeeds( Provider provider, Organization organization, SutProvider sutProvider) { - organization.Seats = 10; - - organization.PlanType = PlanType.TeamsMonthly; - - // Scale up 10 seats - const int seats = 20; + organization.GatewayCustomerId = null; + organization.Name = "Name"; + organization.BusinessName = "BusinessName"; - var providerPlans = new List + var providerCustomer = new Customer { - new() + Address = new Address { - Id = Guid.NewGuid(), - PlanType = PlanType.TeamsMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - // 100 minimum - SeatMinimum = 100, - AllocatedSeats = 50 + Country = "USA", + PostalCode = "12345", + Line1 = "123 Main St.", + Line2 = "Unit 4", + City = "Fake Town", + State = "Fake State" }, - new() + TaxIds = new StripeList { - Id = Guid.NewGuid(), - PlanType = PlanType.EnterpriseMonthly, - ProviderId = provider.Id, - PurchasedSeats = 0, - SeatMinimum = 500, - AllocatedSeats = 0 + Data = + [ + new TaxId { Type = "TYPE", Value = "VALUE" } + ] } }; - var providerPlan = providerPlans.First(); + sutProvider.GetDependency().GetCustomerOrThrow(provider, Arg.Is( + options => options.Expand.FirstOrDefault() == "tax_ids")) + .Returns(providerCustomer); - sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + sutProvider.GetDependency().BaseServiceUri + .Returns(new Bit.Core.Settings.GlobalSettings.BaseServiceUriSettings(new Bit.Core.Settings.GlobalSettings()) + { + CloudRegion = "US" + }); - // 50 seats currently assigned with a seat minimum of 100 - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( + options => + options.Address.Country == providerCustomer.Address.Country && + options.Address.PostalCode == providerCustomer.Address.PostalCode && + options.Address.Line1 == providerCustomer.Address.Line1 && + options.Address.Line2 == providerCustomer.Address.Line2 && + options.Address.City == providerCustomer.Address.City && + options.Address.State == providerCustomer.Address.State && + options.Name == organization.DisplayName() && + options.Description == $"{provider.Name} Client Organization" && + options.Email == provider.BillingEmail && + options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" && + options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" && + options.Metadata["region"] == "US" && + options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type && + options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value)) + .Returns(new Customer { Id = "customer_id" }); - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); - sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( - [ - new ProviderOrganizationOrganizationDetails - { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 25 - }, - new ProviderOrganizationOrganizationDetails + await sutProvider.GetDependency().Received(1).CustomerCreateAsync(Arg.Is( + options => + options.Address.Country == providerCustomer.Address.Country && + options.Address.PostalCode == providerCustomer.Address.PostalCode && + options.Address.Line1 == providerCustomer.Address.Line1 && + options.Address.Line2 == providerCustomer.Address.Line2 && + options.Address.City == providerCustomer.Address.City && + options.Address.State == providerCustomer.Address.State && + options.Name == organization.DisplayName() && + options.Description == $"{provider.Name} Client Organization" && + options.Email == provider.BillingEmail && + options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" && + options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" && + options.Metadata["region"] == "US" && + options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type && + options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value)); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( + org => org.GatewayCustomerId == "customer_id")); + } + + #endregion + + #region GenerateClientInvoiceReport + + [Theory, BitAutoData] + public async Task GenerateClientInvoiceReport_NullInvoiceId_ThrowsArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.GenerateClientInvoiceReport(null)); + + [Theory, BitAutoData] + public async Task GenerateClientInvoiceReport_NoInvoiceItems_ReturnsNull( + string invoiceId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByInvoiceId(invoiceId).Returns([]); + + var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId); + + Assert.Null(reportContent); + } + + [Theory, BitAutoData] + public async Task GenerateClientInvoiceReport_Succeeds( + string invoiceId, + SutProvider sutProvider) + { + var clientId = Guid.NewGuid(); + + var invoiceItems = new List + { + new () { - Plan = teamsMonthlyPlan.Name, - Status = OrganizationStatusType.Managed, - Seats = 25 + ClientId = clientId, + ClientName = "Client 1", + AssignedSeats = 50, + UsedSeats = 30, + PlanName = "Teams (Monthly)", + Total = 500 } - ]); + }; - await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); + sutProvider.GetDependency().GetByInvoiceId(invoiceId).Returns(invoiceItems); - // 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()); + var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId); + + using var memoryStream = new MemoryStream(reportContent); + + using var streamReader = new StreamReader(memoryStream); + + using var csvReader = new CsvReader(streamReader, CultureInfo.InvariantCulture); + + var records = csvReader.GetRecords().ToList(); + + Assert.Single(records); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - org => org.Id == organization.Id && org.Seats == seats)); + var record = records.First(); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - pPlan => pPlan.AllocatedSeats == 60)); + Assert.Equal(clientId.ToString(), record.Id); + Assert.Equal("Client 1", record.Client); + Assert.Equal(50, record.Assigned); + Assert.Equal(30, record.Used); + Assert.Equal(20, record.Remaining); + Assert.Equal("Teams (Monthly)", record.Plan); + Assert.Equal("$500.00", record.Total); } + #endregion + + #region ScaleSeats + [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_BelowToAbove_NotProviderAdmin_ContactSupport( + public async Task ScaleSeats_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() @@ -208,9 +376,8 @@ public async Task AssignSeatsToClientOrganization_BelowToAbove_NotProviderAdmin_ PlanType = PlanType.TeamsMonthly, ProviderId = provider.Id, PurchasedSeats = 0, - // 100 minimum SeatMinimum = 100, - AllocatedSeats = 95 + AllocatedSeats = 50 }, new() { @@ -225,9 +392,7 @@ public async Task AssignSeatsToClientOrganization_BelowToAbove_NotProviderAdmin_ sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); - // 95 seats currently assigned with a seat minimum of 100 - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - + // 50 seats currently assigned with a seat minimum of 100 var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( @@ -236,35 +401,34 @@ public async Task AssignSeatsToClientOrganization_BelowToAbove_NotProviderAdmin_ { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, - Seats = 60 + Seats = 25 }, new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, - Seats = 35 + Seats = 25 } ]); - sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); + 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 ThrowsBillingExceptionAsync(() => - sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( + pPlan => pPlan.AllocatedSeats == 60)); } [Theory, BitAutoData] - public async Task AssignSeatsToClientOrganization_BelowToAbove_Succeeds( + public async Task ScaleSeats_BelowToAbove_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() @@ -273,7 +437,6 @@ public async Task AssignSeatsToClientOrganization_BelowToAbove_Succeeds( PlanType = PlanType.TeamsMonthly, ProviderId = provider.Id, PurchasedSeats = 0, - // 100 minimum SeatMinimum = 100, AllocatedSeats = 95 }, @@ -293,8 +456,6 @@ public async Task AssignSeatsToClientOrganization_BelowToAbove_Succeeds( 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( @@ -313,9 +474,7 @@ public async Task AssignSeatsToClientOrganization_BelowToAbove_Succeeds( } ]); - sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - - await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); + 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( @@ -324,29 +483,16 @@ await sutProvider.GetDependency().Received(1).AdjustSeats( providerPlan.SeatMinimum!.Value, 105); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - org => org.Id == organization.Id && org.Seats == seats)); - // 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( + public async Task ScaleSeats_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() @@ -354,9 +500,7 @@ public async Task AssignSeatsToClientOrganization_AboveToAbove_Succeeds( Id = Guid.NewGuid(), PlanType = PlanType.TeamsMonthly, ProviderId = provider.Id, - // 10 additional purchased seats PurchasedSeats = 10, - // 100 seat minimum SeatMinimum = 100, AllocatedSeats = 110 }, @@ -376,8 +520,6 @@ public async Task AssignSeatsToClientOrganization_AboveToAbove_Succeeds( 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( @@ -396,7 +538,7 @@ public async Task AssignSeatsToClientOrganization_AboveToAbove_Succeeds( } ]); - await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); + await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 110 current + 10 seat scale up = 120 seats await sutProvider.GetDependency().Received(1).AdjustSeats( @@ -405,334 +547,149 @@ await sutProvider.GetDependency().Received(1).AdjustSeats( 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)); - } - - #endregion - - #region CreateCustomerForClientOrganization - - [Theory, BitAutoData] - public async Task CreateCustomerForClientOrganization_ProviderNull_ThrowsArgumentNullException( - Organization organization, - SutProvider sutProvider) => - await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateCustomerForClientOrganization(null, organization)); - - [Theory, BitAutoData] - public async Task CreateCustomerForClientOrganization_OrganizationNull_ThrowsArgumentNullException( - Provider provider, - SutProvider sutProvider) => - await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateCustomerForClientOrganization(provider, null)); - - [Theory, BitAutoData] - public async Task CreateCustomerForClientOrganization_HasGatewayCustomerId_NoOp( - Provider provider, - Organization organization, - SutProvider sutProvider) - { - organization.GatewayCustomerId = "customer_id"; - - await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .GetCustomerOrThrow(Arg.Any(), Arg.Any()); - } - - [Theory, BitAutoData] - public async Task CreateCustomer_ForClientOrg_Succeeds( + public async Task ScaleSeats_AboveToBelow_Succeeds( Provider provider, - Organization organization, - SutProvider sutProvider) - { - organization.GatewayCustomerId = null; - organization.Name = "Name"; - organization.BusinessName = "BusinessName"; - - var providerCustomer = new Customer - { - Address = new Address - { - Country = "USA", - PostalCode = "12345", - Line1 = "123 Main St.", - Line2 = "Unit 4", - City = "Fake Town", - State = "Fake State" - }, - TaxIds = new StripeList - { - Data = - [ - new TaxId { Type = "TYPE", Value = "VALUE" } - ] - } - }; - - sutProvider.GetDependency().GetCustomerOrThrow(provider, Arg.Is( - options => options.Expand.FirstOrDefault() == "tax_ids")) - .Returns(providerCustomer); - - sutProvider.GetDependency().BaseServiceUri - .Returns(new Bit.Core.Settings.GlobalSettings.BaseServiceUriSettings(new Bit.Core.Settings.GlobalSettings()) - { - CloudRegion = "US" - }); - - sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( - options => - options.Address.Country == providerCustomer.Address.Country && - options.Address.PostalCode == providerCustomer.Address.PostalCode && - options.Address.Line1 == providerCustomer.Address.Line1 && - options.Address.Line2 == providerCustomer.Address.Line2 && - options.Address.City == providerCustomer.Address.City && - options.Address.State == providerCustomer.Address.State && - options.Name == organization.DisplayName() && - options.Description == $"{provider.Name} Client Organization" && - options.Email == provider.BillingEmail && - options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" && - options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" && - options.Metadata["region"] == "US" && - options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type && - options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value)) - .Returns(new Customer { Id = "customer_id" }); - - await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); - - await sutProvider.GetDependency().Received(1).CustomerCreateAsync(Arg.Is( - options => - options.Address.Country == providerCustomer.Address.Country && - options.Address.PostalCode == providerCustomer.Address.PostalCode && - options.Address.Line1 == providerCustomer.Address.Line1 && - options.Address.Line2 == providerCustomer.Address.Line2 && - options.Address.City == providerCustomer.Address.City && - options.Address.State == providerCustomer.Address.State && - options.Name == organization.DisplayName() && - options.Description == $"{provider.Name} Client Organization" && - options.Email == provider.BillingEmail && - options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" && - options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" && - options.Metadata["region"] == "US" && - options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type && - options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value)); - - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( - org => org.GatewayCustomerId == "customer_id")); - } - - #endregion - - #region GenerateClientInvoiceReport - - [Theory, BitAutoData] - public async Task GenerateClientInvoiceReport_NullInvoiceId_ThrowsArgumentNullException( - SutProvider sutProvider) => - await Assert.ThrowsAsync(() => sutProvider.Sut.GenerateClientInvoiceReport(null)); - - [Theory, BitAutoData] - public async Task GenerateClientInvoiceReport_NoInvoiceItems_ReturnsNull( - string invoiceId, - SutProvider sutProvider) - { - sutProvider.GetDependency().GetByInvoiceId(invoiceId).Returns([]); - - var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId); - - Assert.Null(reportContent); - } - - [Theory, BitAutoData] - public async Task GenerateClientInvoiceReport_Succeeds( - string invoiceId, SutProvider sutProvider) { - var clientId = Guid.NewGuid(); - - var invoiceItems = new List + var providerPlans = new List { - new () + new() { - ClientId = clientId, - ClientName = "Client 1", - AssignedSeats = 50, - UsedSeats = 30, - PlanName = "Teams (Monthly)", - Total = 500 + 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 } }; - sutProvider.GetDependency().GetByInvoiceId(invoiceId).Returns(invoiceItems); - - var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId); - - using var memoryStream = new MemoryStream(reportContent); + var providerPlan = providerPlans.First(); - using var streamReader = new StreamReader(memoryStream); + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); - using var csvReader = new CsvReader(streamReader, CultureInfo.InvariantCulture); + // 110 seats currently assigned with a seat minimum of 100 + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - var records = csvReader.GetRecords().ToList(); + 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 + } + ]); - Assert.Single(records); + await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30); - var record = records.First(); + // 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); - Assert.Equal(clientId.ToString(), record.Id); - Assert.Equal("Client 1", record.Client); - Assert.Equal(50, record.Assigned); - Assert.Equal(30, record.Used); - Assert.Equal(20, record.Remaining); - Assert.Equal("Teams (Monthly)", record.Plan); - Assert.Equal("$500.00", record.Total); + // 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 GetAssignedSeatTotalForPlanOrThrow + #region SeatAdjustmentResultsInPurchase [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 SeatAdjustmentResultsInPurchase_BelowToAbove_True( Provider provider, + PlanType planType, SutProvider sutProvider) { - provider.Type = ProviderType.Reseller; + 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 + } + ]); - sutProvider.GetDependency().GetByIdAsync(providerId).Returns(provider); + const int seatAdjustment = 10; + + var result = await sutProvider.Sut.SeatAdjustmentResultsInPurchase( + provider, + planType, + seatAdjustment); - await ThrowsBillingExceptionAsync( - () => sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly)); + Assert.True(result); } [Theory, BitAutoData] - public async Task GetAssignedSeatTotalForPlanOrThrow_Succeeds( - Guid providerId, + public async Task SeatAdjustmentResultsInPurchase_AboveToFurtherAbove_True( Provider provider, + PlanType planType, 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 - { - new() { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 10 }, - new() { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 10 }, - new() + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns([ + new ProviderPlan { - // Ignored because of status. - Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Created, Seats = 100 - }, - new() + PlanType = planType, + SeatMinimum = 10, + AllocatedSeats = 0, + PurchasedSeats = 5 + } + ]); + + sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( + [ + new ProviderOrganizationOrganizationDetails { - // Ignored because of plan. - Plan = enterpriseMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 30 + Plan = StaticStore.GetPlan(planType).Name, + Status = OrganizationStatusType.Managed, + Seats = 15 } - }; + ]); - sutProvider.GetDependency() - .GetManyDetailsByProviderAsync(providerId) - .Returns(providerOrganizationOrganizationDetailList); + const int seatAdjustment = 5; - var assignedSeatTotal = - await sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly); + var result = await sutProvider.Sut.SeatAdjustmentResultsInPurchase( + provider, + planType, + seatAdjustment); - Assert.Equal(20, assignedSeatTotal); + Assert.True(result); } #endregion @@ -1012,158 +969,6 @@ public async Task SetupSubscription_Succeeds( #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 async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestExcepti 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 async Task UpdateSeatMinimums_NoPurchasedSeats_AllocatedHigherThanIncomin // 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 async Task UpdateSeatMinimums_NoPurchasedSeats_AllocatedHigherThanIncomin 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 async Task UpdateSeatMinimums_NoPurchasedSeats_AllocatedLowerThanIncoming // 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 async Task UpdateSeatMinimums_PurchasedSeats_NewMinimumLessThanTotal_Upda // 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 async Task UpdateSeatMinimums_PurchasedSeats_NewMinimumGreaterThanTotal_C // 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 async Task UpdateSeatMinimums_SinglePlanTypeUpdate_Succeeds( // 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 efab8620c0d7..db41e9282d7e 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -5,8 +5,10 @@ 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 async Task Edit(Guid id) [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 async Task Edit(Guid id, OrganizationEditModel model) 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 @@ await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider( 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 @@ private async Task GetOrganization(Guid id, OrganizationEditModel organization.GatewayCustomerId = model.GatewayCustomerId; organization.GatewaySubscriptionId = model.GatewaySubscriptionId; } + } + + 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; - return organization; + 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 81b8b29f2826..5f7005fdfca2 100644 --- a/src/Api/Billing/Controllers/BaseBillingController.cs +++ b/src/Api/Billing/Controllers/BaseBillingController.cs @@ -22,9 +22,9 @@ public static JsonHttpResult ServerError(string message = "S 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 23a6da4590a5..700dd4a2e446 100644 --- a/src/Api/Billing/Controllers/ProviderClientsController.cs +++ b/src/Api/Billing/Controllers/ProviderClientsController.cs @@ -102,15 +102,27 @@ public async Task UpdateAsync( 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 e353e551598c..20e74076282e 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 @@ Task CreateCustomerForClientOrganization( 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 @@ Task ScaleSeats( 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 @@ Task SetupCustomer( 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 67d463bac9c5..5830b480eff9 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 000000000000..af98fef03075 --- /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 450ff9bf25c5..86bacd9aa3a5 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.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 @@ await sutProvider.GetDependency().Received(1).CreateCus #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 async Task UpdateAsync_AssignedSeats_Ok( 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,22 +117,20 @@ public async Task UpdateAsync_AssignedSeats_Ok( sutProvider.GetDependency().GetByIdAsync(providerOrganization.OrganizationId) .Returns(organization); - var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody); + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); - await sutProvider.GetDependency().Received(1) - .AssignSeatsToClientOrganization( - provider, - organization, - requestBody.AssignedSeats); + sutProvider.GetDependency().SeatAdjustmentResultsInPurchase( + provider, + PlanType.TeamsMonthly, + 10).Returns(true); - await sutProvider.GetDependency().Received(1) - .ReplaceAsync(Arg.Is(org => org.Name == requestBody.Name)); + var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody); - Assert.IsType(result); + AssertUnauthorized(result, message: "Service users cannot purchase additional seats."); } [Theory, BitAutoData] - public async Task UpdateAsync_Name_Ok( + public async Task UpdateAsync_Ok( Provider provider, Guid providerOrganizationId, UpdateClientOrganizationRequestBody requestBody, @@ -149,6 +138,11 @@ public async Task UpdateAsync_Name_Ok( 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) @@ -157,18 +151,23 @@ public async Task UpdateAsync_Name_Ok( sutProvider.GetDependency().GetByIdAsync(providerOrganization.OrganizationId) .Returns(organization); - requestBody.AssignedSeats = organization.Seats!.Value; + 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().DidNotReceiveWithAnyArgs() - .AssignSeatsToClientOrganization( - Arg.Any(), - Arg.Any(), - Arg.Any()); + await sutProvider.GetDependency().Received(1) + .ScaleSeats( + provider, + PlanType.TeamsMonthly, + 10); 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 36291ec714db..3f5eee72cddf 100644 --- a/test/Api.Test/Billing/Utilities.cs +++ b/test/Api.Test/Billing/Utilities.cs @@ -25,14 +25,14 @@ public static void AssertNotFound(IResult result) 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(