diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index db870266cc3f..788908d42a19 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -88,7 +88,7 @@ public void ConfigureServices(IServiceCollection services) services.AddBaseServices(globalSettings); services.AddDefaultServices(globalSettings); services.AddScoped(); - services.AddBillingCommands(); + services.AddBillingOperations(); #if OSS services.AddOosServices(); diff --git a/src/Api/Billing/Controllers/ProviderOrganizationController.cs b/src/Api/Billing/Controllers/ProviderOrganizationController.cs new file mode 100644 index 000000000000..8760415f5efd --- /dev/null +++ b/src/Api/Billing/Controllers/ProviderOrganizationController.cs @@ -0,0 +1,63 @@ +using Bit.Api.Billing.Models; +using Bit.Core; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; +using Bit.Core.Context; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Billing.Controllers; + +[Route("providers/{providerId:guid}/organizations")] +public class ProviderOrganizationController( + IAssignSeatsToClientOrganizationCommand assignSeatsToClientOrganizationCommand, + ICurrentContext currentContext, + IFeatureService featureService, + ILogger logger, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IProviderOrganizationRepository providerOrganizationRepository) : Controller +{ + [HttpPut("{providerOrganizationId:guid}")] + public async Task UpdateAsync( + [FromRoute] Guid providerId, + [FromRoute] Guid providerOrganizationId, + [FromBody] UpdateProviderOrganizationRequestBody requestBody) + { + if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) + { + return TypedResults.NotFound(); + } + + if (!currentContext.ProviderProviderAdmin(providerId)) + { + return TypedResults.Unauthorized(); + } + + var provider = await providerRepository.GetByIdAsync(providerId); + + var providerOrganization = await providerOrganizationRepository.GetByIdAsync(providerOrganizationId); + + if (provider == null || providerOrganization == null) + { + return TypedResults.NotFound(); + } + + var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + + if (organization == null) + { + logger.LogError("The organization ({OrganizationID}) represented by provider organization ({ProviderOrganizationID}) could not be found.", providerOrganization.OrganizationId, providerOrganization.Id); + + return TypedResults.Problem(); + } + + await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization( + provider, + organization, + requestBody.AssignedSeats); + + return TypedResults.NoContent(); + } +} diff --git a/src/Api/Billing/Models/ProviderSubscriptionDTO.cs b/src/Api/Billing/Models/ProviderSubscriptionDTO.cs index 0e8b8bfb1cff..ad0714967d7a 100644 --- a/src/Api/Billing/Models/ProviderSubscriptionDTO.cs +++ b/src/Api/Billing/Models/ProviderSubscriptionDTO.cs @@ -27,6 +27,7 @@ public record ProviderSubscriptionDTO( plan.Name, providerPlan.SeatMinimum, providerPlan.PurchasedSeats, + providerPlan.AssignedSeats, cost, cadence); }); @@ -43,5 +44,6 @@ public record ProviderPlanDTO( string PlanName, int SeatMinimum, int PurchasedSeats, + int AssignedSeats, decimal Cost, string Cadence); diff --git a/src/Api/Billing/Models/UpdateProviderOrganizationRequestBody.cs b/src/Api/Billing/Models/UpdateProviderOrganizationRequestBody.cs new file mode 100644 index 000000000000..7bac8fdef448 --- /dev/null +++ b/src/Api/Billing/Models/UpdateProviderOrganizationRequestBody.cs @@ -0,0 +1,6 @@ +namespace Bit.Api.Billing.Models; + +public class UpdateProviderOrganizationRequestBody +{ + public int AssignedSeats { get; set; } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 9f94325513af..63b1a3c3cd46 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -170,8 +170,7 @@ public void ConfigureServices(IServiceCollection services) services.AddDefaultServices(globalSettings); services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); - services.AddBillingCommands(); - services.AddBillingQueries(); + services.AddBillingOperations(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Core/Billing/Commands/IAssignSeatsToClientOrganizationCommand.cs b/src/Core/Billing/Commands/IAssignSeatsToClientOrganizationCommand.cs new file mode 100644 index 000000000000..db21926bec2a --- /dev/null +++ b/src/Core/Billing/Commands/IAssignSeatsToClientOrganizationCommand.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; + +namespace Bit.Core.Billing.Commands; + +public interface IAssignSeatsToClientOrganizationCommand +{ + Task AssignSeatsToClientOrganization( + Provider provider, + Organization organization, + int seats); +} diff --git a/src/Core/Billing/Commands/Implementations/AssignSeatsToClientOrganizationCommand.cs b/src/Core/Billing/Commands/Implementations/AssignSeatsToClientOrganizationCommand.cs new file mode 100644 index 000000000000..be2c6be968d1 --- /dev/null +++ b/src/Core/Billing/Commands/Implementations/AssignSeatsToClientOrganizationCommand.cs @@ -0,0 +1,174 @@ +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.Extensions; +using Bit.Core.Billing.Queries; +using Bit.Core.Billing.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using static Bit.Core.Billing.Utilities; + +namespace Bit.Core.Billing.Commands.Implementations; + +public class AssignSeatsToClientOrganizationCommand( + ILogger logger, + IOrganizationRepository organizationRepository, + IPaymentService paymentService, + IProviderBillingQueries providerBillingQueries, + IProviderPlanRepository providerPlanRepository) : IAssignSeatsToClientOrganizationCommand +{ + public async Task AssignSeatsToClientOrganization( + Provider provider, + Organization organization, + int seats) + { + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(organization); + + if (provider.Type == ProviderType.Reseller) + { + logger.LogError("Reseller-type provider ({ID}) cannot assign seats to client organizations", provider.Id); + + throw ContactSupport("Consolidated billing does not support reseller-type providers"); + } + + if (seats < 0) + { + throw new BillingException( + "You cannot assign negative seats to a client.", + "MSP cannot assign negative seats to a client organization"); + } + + if (seats == organization.Seats) + { + logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned", organization.Id, organization.Seats); + + return; + } + + var providerPlan = await GetProviderPlanAsync(provider, organization); + + var providerSeatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0); + + // How many seats the provider has assigned to all their client organizations that have the specified plan type. + var providerCurrentlyAssignedSeatTotal = await providerBillingQueries.GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType); + + // How many seats are being added to or subtracted from this client organization. + var seatDifference = seats - (organization.Seats ?? 0); + + // How many seats the provider will have assigned to all of their client organizations after the update. + var providerNewlyAssignedSeatTotal = providerCurrentlyAssignedSeatTotal + seatDifference; + + var update = CurryUpdateFunction( + provider, + providerPlan, + organization, + seats, + providerNewlyAssignedSeatTotal); + + /* + * Below the limit => Below the limit: + * No subscription update required. We can safely update the organization's seats. + */ + if (providerCurrentlyAssignedSeatTotal <= providerSeatMinimum && + providerNewlyAssignedSeatTotal <= providerSeatMinimum) + { + organization.Seats = seats; + + await organizationRepository.ReplaceAsync(organization); + + providerPlan.AllocatedSeats = providerNewlyAssignedSeatTotal; + + await providerPlanRepository.ReplaceAsync(providerPlan); + } + /* + * Below the limit => Above the limit: + * We have to scale the subscription up from the seat minimum to the newly assigned seat total. + */ + else if (providerCurrentlyAssignedSeatTotal <= providerSeatMinimum && + providerNewlyAssignedSeatTotal > providerSeatMinimum) + { + await update( + providerSeatMinimum, + providerNewlyAssignedSeatTotal); + } + /* + * Above the limit => Above the limit: + * We have to scale the subscription from the currently assigned seat total to the newly assigned seat total. + */ + else if (providerCurrentlyAssignedSeatTotal > providerSeatMinimum && + providerNewlyAssignedSeatTotal > providerSeatMinimum) + { + await update( + providerCurrentlyAssignedSeatTotal, + providerNewlyAssignedSeatTotal); + } + /* + * Above the limit => Below the limit: + * We have to scale the subscription down from the currently assigned seat total to the seat minimum. + */ + else if (providerCurrentlyAssignedSeatTotal > providerSeatMinimum && + providerNewlyAssignedSeatTotal <= providerSeatMinimum) + { + await update( + providerCurrentlyAssignedSeatTotal, + providerSeatMinimum); + } + } + + // ReSharper disable once SuggestBaseTypeForParameter + private async Task GetProviderPlanAsync(Provider provider, Organization organization) + { + if (!organization.PlanType.SupportsConsolidatedBilling()) + { + logger.LogError("Cannot assign seats to a client organization ({ID}) with a plan type that does not support consolidated billing: {PlanType}", organization.Id, organization.PlanType); + + throw ContactSupport(); + } + + var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + + var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == organization.PlanType); + + if (providerPlan != null && providerPlan.IsConfigured()) + { + return providerPlan; + } + + logger.LogError("Cannot assign seats to client organization ({ClientOrganizationID}) when provider's ({ProviderID}) matching plan is not configured", organization.Id, provider.Id); + + throw ContactSupport(); + } + + private Func CurryUpdateFunction( + Provider provider, + ProviderPlan providerPlan, + Organization organization, + int organizationNewlyAssignedSeats, + int providerNewlyAssignedSeats) => async (providerCurrentlySubscribedSeats, providerNewlySubscribedSeats) => + { + var plan = StaticStore.GetPlan(providerPlan.PlanType); + + await paymentService.AdjustSeats( + provider, + plan, + providerCurrentlySubscribedSeats, + providerNewlySubscribedSeats); + + organization.Seats = organizationNewlyAssignedSeats; + + await organizationRepository.ReplaceAsync(organization); + + var providerNewlyPurchasedSeats = providerNewlySubscribedSeats > providerPlan.SeatMinimum + ? providerNewlySubscribedSeats - providerPlan.SeatMinimum + : 0; + + providerPlan.PurchasedSeats = providerNewlyPurchasedSeats; + providerPlan.AllocatedSeats = providerNewlyAssignedSeats; + + await providerPlanRepository.ReplaceAsync(providerPlan); + }; +} diff --git a/src/Core/Billing/Entities/ProviderPlan.cs b/src/Core/Billing/Entities/ProviderPlan.cs index 2f15a539e10d..f4965570d93f 100644 --- a/src/Core/Billing/Entities/ProviderPlan.cs +++ b/src/Core/Billing/Entities/ProviderPlan.cs @@ -11,6 +11,7 @@ public class ProviderPlan : ITableObject public PlanType PlanType { get; set; } public int? SeatMinimum { get; set; } public int? PurchasedSeats { get; set; } + public int? AllocatedSeats { get; set; } public void SetNewId() { @@ -20,5 +21,5 @@ public void SetNewId() } } - public bool Configured => SeatMinimum.HasValue && PurchasedSeats.HasValue; + public bool IsConfigured() => SeatMinimum.HasValue && PurchasedSeats.HasValue && AllocatedSeats.HasValue; } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs new file mode 100644 index 000000000000..c7abeb81e211 --- /dev/null +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -0,0 +1,9 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Billing.Extensions; + +public static class BillingExtensions +{ + public static bool SupportsConsolidatedBilling(this PlanType planType) + => planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly; +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 751bfdb6715b..8e28b233974e 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -9,15 +9,15 @@ namespace Bit.Core.Billing.Extensions; public static class ServiceCollectionExtensions { - public static void AddBillingCommands(this IServiceCollection services) + public static void AddBillingOperations(this IServiceCollection services) { - services.AddSingleton(); - services.AddSingleton(); - } + // Queries + services.AddTransient(); + services.AddTransient(); - public static void AddBillingQueries(this IServiceCollection services) - { - services.AddSingleton(); - services.AddSingleton(); + // Commands + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } } diff --git a/src/Core/Billing/Models/ConfiguredProviderPlan.cs b/src/Core/Billing/Models/ConfiguredProviderPlan.cs index d5d53b36fa20..d6bc2b7522d4 100644 --- a/src/Core/Billing/Models/ConfiguredProviderPlan.cs +++ b/src/Core/Billing/Models/ConfiguredProviderPlan.cs @@ -8,15 +8,17 @@ public record ConfiguredProviderPlan( Guid ProviderId, PlanType PlanType, int SeatMinimum, - int PurchasedSeats) + int PurchasedSeats, + int AssignedSeats) { public static ConfiguredProviderPlan From(ProviderPlan providerPlan) => - providerPlan.Configured + providerPlan.IsConfigured() ? new ConfiguredProviderPlan( providerPlan.Id, providerPlan.ProviderId, providerPlan.PlanType, providerPlan.SeatMinimum.GetValueOrDefault(0), - providerPlan.PurchasedSeats.GetValueOrDefault(0)) + providerPlan.PurchasedSeats.GetValueOrDefault(0), + providerPlan.AllocatedSeats.GetValueOrDefault(0)) : null; } diff --git a/src/Core/Billing/Queries/IProviderBillingQueries.cs b/src/Core/Billing/Queries/IProviderBillingQueries.cs index 1edfddaf5660..e4b7d0f14dd6 100644 --- a/src/Core/Billing/Queries/IProviderBillingQueries.cs +++ b/src/Core/Billing/Queries/IProviderBillingQueries.cs @@ -1,9 +1,22 @@ -using Bit.Core.Billing.Models; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing.Models; +using Bit.Core.Enums; namespace Bit.Core.Billing.Queries; public interface IProviderBillingQueries { + /// + /// 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); + /// /// Retrieves a provider's billing subscription data. /// diff --git a/src/Core/Billing/Queries/Implementations/ProviderBillingQueries.cs b/src/Core/Billing/Queries/Implementations/ProviderBillingQueries.cs index c921e829694d..f8bff9d3fd76 100644 --- a/src/Core/Billing/Queries/Implementations/ProviderBillingQueries.cs +++ b/src/Core/Billing/Queries/Implementations/ProviderBillingQueries.cs @@ -1,17 +1,53 @@ -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Models; using Bit.Core.Billing.Repositories; +using Bit.Core.Enums; +using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; +using static Bit.Core.Billing.Utilities; namespace Bit.Core.Billing.Queries.Implementations; public class ProviderBillingQueries( ILogger logger, + IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, IProviderRepository providerRepository, ISubscriberQueries subscriberQueries) : IProviderBillingQueries { + 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 ContactSupport(); + } + + if (provider.Type == ProviderType.Reseller) + { + logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId); + + throw ContactSupport("Consolidated billing does not support reseller-type providers"); + } + + var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId); + + var plan = StaticStore.GetPlan(planType); + + return providerOrganizations + .Where(providerOrganization => providerOrganization.Plan == plan.Name) + .Sum(providerOrganization => providerOrganization.Seats ?? 0); + } + public async Task GetSubscriptionData(Guid providerId) { var provider = await providerRepository.GetByIdAsync(providerId); @@ -25,6 +61,13 @@ public async Task GetSubscriptionData(Guid providerId) return null; } + if (provider.Type == ProviderType.Reseller) + { + logger.LogError("Subscription data cannot be retrieved for reseller-type provider ({ID})", providerId); + + throw ContactSupport("Consolidated billing does not support reseller-type providers"); + } + var subscription = await subscriberQueries.GetSubscription(provider, new SubscriptionGetOptions { Expand = ["customer"] @@ -38,7 +81,7 @@ public async Task GetSubscriptionData(Guid providerId) var providerPlans = await providerPlanRepository.GetByProviderId(providerId); var configuredProviderPlans = providerPlans - .Where(providerPlan => providerPlan.Configured) + .Where(providerPlan => providerPlan.IsConfigured()) .Select(ConfiguredProviderPlan.From) .ToList(); diff --git a/src/Core/Models/Business/CompleteSubscriptionUpdate.cs b/src/Core/Models/Business/CompleteSubscriptionUpdate.cs index a1146cd2a0f7..aa1c92dc2e3f 100644 --- a/src/Core/Models/Business/CompleteSubscriptionUpdate.cs +++ b/src/Core/Models/Business/CompleteSubscriptionUpdate.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.Enums; using Bit.Core.Exceptions; using Stripe; @@ -279,25 +278,6 @@ public override List UpgradeItemsOptions(Subscription s }; } - private static SubscriptionItem FindSubscriptionItem(Subscription subscription, string planId) - { - if (string.IsNullOrEmpty(planId)) - { - return null; - } - - var data = subscription.Items.Data; - - var subscriptionItem = data.FirstOrDefault(item => item.Plan?.Id == planId) ?? data.FirstOrDefault(item => item.Price?.Id == planId); - - return subscriptionItem; - } - - private static string GetPasswordManagerPlanId(StaticStore.Plan plan) - => IsNonSeatBasedPlan(plan) - ? plan.PasswordManager.StripePlanId - : plan.PasswordManager.StripeSeatPlanId; - private static SubscriptionData GetSubscriptionDataFor(Organization organization) { var plan = Utilities.StaticStore.GetPlan(organization.PlanType); @@ -320,10 +300,4 @@ private static SubscriptionData GetSubscriptionDataFor(Organization organization 0 }; } - - private static bool IsNonSeatBasedPlan(StaticStore.Plan plan) - => plan.Type is - >= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019 - or PlanType.FamiliesAnnually - or PlanType.TeamsStarter; } diff --git a/src/Core/Models/Business/ProviderSubscriptionUpdate.cs b/src/Core/Models/Business/ProviderSubscriptionUpdate.cs new file mode 100644 index 000000000000..8b29bebce597 --- /dev/null +++ b/src/Core/Models/Business/ProviderSubscriptionUpdate.cs @@ -0,0 +1,61 @@ +using Bit.Core.Billing.Extensions; +using Bit.Core.Enums; +using Stripe; + +using static Bit.Core.Billing.Utilities; + +namespace Bit.Core.Models.Business; + +public class ProviderSubscriptionUpdate : SubscriptionUpdate +{ + private readonly string _planId; + private readonly int _previouslyPurchasedSeats; + private readonly int _newlyPurchasedSeats; + + protected override List PlanIds => [_planId]; + + public ProviderSubscriptionUpdate( + PlanType planType, + int previouslyPurchasedSeats, + int newlyPurchasedSeats) + { + if (!planType.SupportsConsolidatedBilling()) + { + throw ContactSupport($"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing"); + } + + _planId = GetPasswordManagerPlanId(Utilities.StaticStore.GetPlan(planType)); + _previouslyPurchasedSeats = previouslyPurchasedSeats; + _newlyPurchasedSeats = newlyPurchasedSeats; + } + + public override List RevertItemsOptions(Subscription subscription) + { + var subscriptionItem = FindSubscriptionItem(subscription, _planId); + + return + [ + new SubscriptionItemOptions + { + Id = subscriptionItem.Id, + Price = _planId, + Quantity = _previouslyPurchasedSeats + } + ]; + } + + public override List UpgradeItemsOptions(Subscription subscription) + { + var subscriptionItem = FindSubscriptionItem(subscription, _planId); + + return + [ + new SubscriptionItemOptions + { + Id = subscriptionItem.Id, + Price = _planId, + Quantity = _newlyPurchasedSeats + } + ]; + } +} diff --git a/src/Core/Models/Business/SeatSubscriptionUpdate.cs b/src/Core/Models/Business/SeatSubscriptionUpdate.cs index c5ea1a74741c..db5104ddd245 100644 --- a/src/Core/Models/Business/SeatSubscriptionUpdate.cs +++ b/src/Core/Models/Business/SeatSubscriptionUpdate.cs @@ -18,7 +18,7 @@ public SeatSubscriptionUpdate(Organization organization, StaticStore.Plan plan, public override List UpgradeItemsOptions(Subscription subscription) { - var item = SubscriptionItem(subscription, PlanIds.Single()); + var item = FindSubscriptionItem(subscription, PlanIds.Single()); return new() { new SubscriptionItemOptions @@ -34,7 +34,7 @@ public override List UpgradeItemsOptions(Subscription s public override List RevertItemsOptions(Subscription subscription) { - var item = SubscriptionItem(subscription, PlanIds.Single()); + var item = FindSubscriptionItem(subscription, PlanIds.Single()); return new() { new SubscriptionItemOptions diff --git a/src/Core/Models/Business/ServiceAccountSubscriptionUpdate.cs b/src/Core/Models/Business/ServiceAccountSubscriptionUpdate.cs index c93212eac831..c3e3e0999254 100644 --- a/src/Core/Models/Business/ServiceAccountSubscriptionUpdate.cs +++ b/src/Core/Models/Business/ServiceAccountSubscriptionUpdate.cs @@ -19,7 +19,7 @@ public ServiceAccountSubscriptionUpdate(Organization organization, StaticStore.P public override List UpgradeItemsOptions(Subscription subscription) { - var item = SubscriptionItem(subscription, PlanIds.Single()); + var item = FindSubscriptionItem(subscription, PlanIds.Single()); _prevServiceAccounts = item?.Quantity ?? 0; return new() { @@ -35,7 +35,7 @@ public override List UpgradeItemsOptions(Subscription s public override List RevertItemsOptions(Subscription subscription) { - var item = SubscriptionItem(subscription, PlanIds.Single()); + var item = FindSubscriptionItem(subscription, PlanIds.Single()); return new() { new SubscriptionItemOptions diff --git a/src/Core/Models/Business/SmSeatSubscriptionUpdate.cs b/src/Core/Models/Business/SmSeatSubscriptionUpdate.cs index ff6bb550111b..b8201b97759f 100644 --- a/src/Core/Models/Business/SmSeatSubscriptionUpdate.cs +++ b/src/Core/Models/Business/SmSeatSubscriptionUpdate.cs @@ -19,7 +19,7 @@ public SmSeatSubscriptionUpdate(Organization organization, StaticStore.Plan plan public override List UpgradeItemsOptions(Subscription subscription) { - var item = SubscriptionItem(subscription, PlanIds.Single()); + var item = FindSubscriptionItem(subscription, PlanIds.Single()); return new() { new SubscriptionItemOptions @@ -35,7 +35,7 @@ public override List UpgradeItemsOptions(Subscription s public override List RevertItemsOptions(Subscription subscription) { - var item = SubscriptionItem(subscription, PlanIds.Single()); + var item = FindSubscriptionItem(subscription, PlanIds.Single()); return new() { new SubscriptionItemOptions diff --git a/src/Core/Models/Business/SponsorOrganizationSubscriptionUpdate.cs b/src/Core/Models/Business/SponsorOrganizationSubscriptionUpdate.cs index 88af72f1997a..59a745297b0c 100644 --- a/src/Core/Models/Business/SponsorOrganizationSubscriptionUpdate.cs +++ b/src/Core/Models/Business/SponsorOrganizationSubscriptionUpdate.cs @@ -74,10 +74,10 @@ public override List UpgradeItemsOptions(Subscription s private string AddStripePlanId => _applySponsorship ? _sponsoredPlanStripeId : _existingPlanStripeId; private Stripe.SubscriptionItem RemoveStripeItem(Subscription subscription) => _applySponsorship ? - SubscriptionItem(subscription, _existingPlanStripeId) : - SubscriptionItem(subscription, _sponsoredPlanStripeId); + FindSubscriptionItem(subscription, _existingPlanStripeId) : + FindSubscriptionItem(subscription, _sponsoredPlanStripeId); private Stripe.SubscriptionItem AddStripeItem(Subscription subscription) => _applySponsorship ? - SubscriptionItem(subscription, _sponsoredPlanStripeId) : - SubscriptionItem(subscription, _existingPlanStripeId); + FindSubscriptionItem(subscription, _sponsoredPlanStripeId) : + FindSubscriptionItem(subscription, _existingPlanStripeId); } diff --git a/src/Core/Models/Business/StorageSubscriptionUpdate.cs b/src/Core/Models/Business/StorageSubscriptionUpdate.cs index 30ab2428e2ad..b0f4a83d3e63 100644 --- a/src/Core/Models/Business/StorageSubscriptionUpdate.cs +++ b/src/Core/Models/Business/StorageSubscriptionUpdate.cs @@ -17,7 +17,7 @@ public StorageSubscriptionUpdate(string plan, long? additionalStorage) public override List UpgradeItemsOptions(Subscription subscription) { - var item = SubscriptionItem(subscription, PlanIds.Single()); + var item = FindSubscriptionItem(subscription, PlanIds.Single()); _prevStorage = item?.Quantity ?? 0; return new() { @@ -38,7 +38,7 @@ public override List RevertItemsOptions(Subscription su throw new Exception("Unknown previous value, must first call UpgradeItemsOptions"); } - var item = SubscriptionItem(subscription, PlanIds.Single()); + var item = FindSubscriptionItem(subscription, PlanIds.Single()); return new() { new SubscriptionItemOptions diff --git a/src/Core/Models/Business/SubscriptionUpdate.cs b/src/Core/Models/Business/SubscriptionUpdate.cs index 70106a10eabb..bba9d384d25d 100644 --- a/src/Core/Models/Business/SubscriptionUpdate.cs +++ b/src/Core/Models/Business/SubscriptionUpdate.cs @@ -1,4 +1,5 @@ -using Stripe; +using Bit.Core.Enums; +using Stripe; namespace Bit.Core.Models.Business; @@ -15,7 +16,7 @@ public virtual bool UpdateNeeded(Subscription subscription) foreach (var upgradeItemOptions in upgradeItemsOptions) { var upgradeQuantity = upgradeItemOptions.Quantity ?? 0; - var existingQuantity = SubscriptionItem(subscription, upgradeItemOptions.Plan)?.Quantity ?? 0; + var existingQuantity = FindSubscriptionItem(subscription, upgradeItemOptions.Plan)?.Quantity ?? 0; if (upgradeQuantity != existingQuantity) { return true; @@ -24,6 +25,28 @@ public virtual bool UpdateNeeded(Subscription subscription) return false; } - protected static SubscriptionItem SubscriptionItem(Subscription subscription, string planId) => - planId == null ? null : subscription.Items?.Data?.FirstOrDefault(i => i.Plan.Id == planId); + protected static SubscriptionItem FindSubscriptionItem(Subscription subscription, string planId) + { + if (string.IsNullOrEmpty(planId)) + { + return null; + } + + var data = subscription.Items.Data; + + var subscriptionItem = data.FirstOrDefault(item => item.Plan?.Id == planId) ?? data.FirstOrDefault(item => item.Price?.Id == planId); + + return subscriptionItem; + } + + protected static string GetPasswordManagerPlanId(StaticStore.Plan plan) + => IsNonSeatBasedPlan(plan) + ? plan.PasswordManager.StripePlanId + : plan.PasswordManager.StripeSeatPlanId; + + protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan) + => plan.Type is + >= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019 + or PlanType.FamiliesAnnually + or PlanType.TeamsStarter; } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index f8f24cfbdbd4..e0d2e95dc9c7 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; @@ -28,6 +29,12 @@ public interface IPaymentService int newlyPurchasedAdditionalStorage, DateTime? prorationDate = null); Task AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); + Task AdjustSeats( + Provider provider, + Plan plan, + int currentlySubscribedSeats, + int newlySubscribedSeats, + DateTime? prorationDate = null); Task AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 19437a1ee2e8..e89bdacfe17f 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Constants; using Bit.Core.Entities; using Bit.Core.Enums; @@ -757,14 +758,14 @@ private async Task ChangeOrganizationSponsorship(Organization org, OrganizationS }).ToList(); } - private async Task FinalizeSubscriptionChangeAsync(IStorableSubscriber storableSubscriber, + private async Task FinalizeSubscriptionChangeAsync(ISubscriber subscriber, SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate, bool invoiceNow = false) { // remember, when in doubt, throw var subGetOptions = new SubscriptionGetOptions(); // subGetOptions.AddExpand("customer"); subGetOptions.AddExpand("customer.tax"); - var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId, subGetOptions); + var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions); if (sub == null) { throw new GatewayException("Subscription not found."); @@ -792,8 +793,8 @@ private async Task ChangeOrganizationSponsorship(Organization org, OrganizationS { var upcomingInvoiceWithChanges = await _stripeAdapter.InvoiceUpcomingAsync(new UpcomingInvoiceOptions { - Customer = storableSubscriber.GatewayCustomerId, - Subscription = storableSubscriber.GatewaySubscriptionId, + Customer = subscriber.GatewayCustomerId, + Subscription = subscriber.GatewaySubscriptionId, SubscriptionItems = ToInvoiceSubscriptionItemOptions(updatedItemOptions), SubscriptionProrationBehavior = Constants.CreateProrations, SubscriptionProrationDate = prorationDate, @@ -862,7 +863,7 @@ private async Task ChangeOrganizationSponsorship(Organization org, OrganizationS { if (chargeNow) { - paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(storableSubscriber, invoice); + paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(subscriber, invoice); } else { @@ -943,6 +944,17 @@ public Task AdjustSeatsAsync(Organization organization, StaticStore.Plan return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); } + public Task AdjustSeats( + Provider provider, + StaticStore.Plan plan, + int currentlySubscribedSeats, + int newlySubscribedSeats, + DateTime? prorationDate = null) + => FinalizeSubscriptionChangeAsync( + provider, + new ProviderSubscriptionUpdate(plan.Type, currentlySubscribedSeats, newlySubscribedSeats), + prorationDate); + public Task AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null) { return FinalizeSubscriptionChangeAsync(organization, new SmSeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index dcf63df1389b..007f3374e015 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -147,7 +147,6 @@ static StaticStore() public static Plan GetPlan(PlanType planType) => Plans.SingleOrDefault(p => p.Type == planType); - public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) => SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType); diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs new file mode 100644 index 000000000000..57480ac11684 --- /dev/null +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -0,0 +1,130 @@ +using Bit.Api.Billing.Controllers; +using Bit.Api.Billing.Models; +using Bit.Core; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Queries; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http.HttpResults; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Xunit; + +namespace Bit.Api.Test.Billing.Controllers; + +[ControllerCustomize(typeof(ProviderBillingController))] +[SutProviderCustomize] +public class ProviderBillingControllerTests +{ + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_FFDisabled_NotFound( + Guid providerId, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(false); + + var result = await sutProvider.Sut.GetSubscriptionAsync(providerId); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_NotProviderAdmin_Unauthorized( + Guid providerId, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(false); + + var result = await sutProvider.Sut.GetSubscriptionAsync(providerId); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_NoSubscriptionData_NotFound( + Guid providerId, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(true); + + sutProvider.GetDependency().GetSubscriptionData(providerId).ReturnsNull(); + + var result = await sutProvider.Sut.GetSubscriptionAsync(providerId); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_OK( + Guid providerId, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(true); + + var configuredPlans = new List + { + new (Guid.NewGuid(), providerId, PlanType.TeamsMonthly, 50, 10, 30), + new (Guid.NewGuid(), providerId, PlanType.EnterpriseMonthly, 100, 0, 90) + }; + + var subscription = new Subscription + { + Status = "active", + CurrentPeriodEnd = new DateTime(2025, 1, 1), + Customer = new Customer { Discount = new Discount { Coupon = new Coupon { PercentOff = 10 } } } + }; + + var providerSubscriptionData = new ProviderSubscriptionData( + configuredPlans, + subscription); + + sutProvider.GetDependency().GetSubscriptionData(providerId) + .Returns(providerSubscriptionData); + + var result = await sutProvider.Sut.GetSubscriptionAsync(providerId); + + Assert.IsType>(result); + + var providerSubscriptionDTO = ((Ok)result).Value; + + Assert.Equal(providerSubscriptionDTO.Status, subscription.Status); + Assert.Equal(providerSubscriptionDTO.CurrentPeriodEndDate, subscription.CurrentPeriodEnd); + Assert.Equal(providerSubscriptionDTO.DiscountPercentage, subscription.Customer!.Discount!.Coupon!.PercentOff); + + var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var providerTeamsPlan = providerSubscriptionDTO.Plans.FirstOrDefault(plan => plan.PlanName == teamsPlan.Name); + Assert.NotNull(providerTeamsPlan); + Assert.Equal(50, providerTeamsPlan.SeatMinimum); + Assert.Equal(10, providerTeamsPlan.PurchasedSeats); + Assert.Equal(30, providerTeamsPlan.AssignedSeats); + Assert.Equal(60 * teamsPlan.PasswordManager.SeatPrice, providerTeamsPlan.Cost); + Assert.Equal("Monthly", providerTeamsPlan.Cadence); + + var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); + var providerEnterprisePlan = providerSubscriptionDTO.Plans.FirstOrDefault(plan => plan.PlanName == enterprisePlan.Name); + Assert.NotNull(providerEnterprisePlan); + Assert.Equal(100, providerEnterprisePlan.SeatMinimum); + Assert.Equal(0, providerEnterprisePlan.PurchasedSeats); + Assert.Equal(90, providerEnterprisePlan.AssignedSeats); + Assert.Equal(100 * enterprisePlan.PasswordManager.SeatPrice, providerEnterprisePlan.Cost); + Assert.Equal("Monthly", providerEnterprisePlan.Cadence); + } +} diff --git a/test/Api.Test/Billing/Controllers/ProviderOrganizationControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderOrganizationControllerTests.cs new file mode 100644 index 000000000000..e75f4bb59ec9 --- /dev/null +++ b/test/Api.Test/Billing/Controllers/ProviderOrganizationControllerTests.cs @@ -0,0 +1,168 @@ +using Bit.Api.Billing.Controllers; +using Bit.Api.Billing.Models; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; +using Bit.Core.Context; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http.HttpResults; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; +using ProviderOrganization = Bit.Core.AdminConsole.Entities.Provider.ProviderOrganization; + +namespace Bit.Api.Test.Billing.Controllers; + +[ControllerCustomize(typeof(ProviderOrganizationController))] +[SutProviderCustomize] +public class ProviderOrganizationControllerTests +{ + [Theory, BitAutoData] + public async Task UpdateAsync_FFDisabled_NotFound( + Guid providerId, + Guid providerOrganizationId, + UpdateProviderOrganizationRequestBody requestBody, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(false); + + var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_NotProviderAdmin_Unauthorized( + Guid providerId, + Guid providerOrganizationId, + UpdateProviderOrganizationRequestBody requestBody, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(false); + + var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_NoProvider_NotFound( + Guid providerId, + Guid providerOrganizationId, + UpdateProviderOrganizationRequestBody requestBody, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(providerId) + .ReturnsNull(); + + var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_NoProviderOrganization_NotFound( + Guid providerId, + Guid providerOrganizationId, + UpdateProviderOrganizationRequestBody requestBody, + Provider provider, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(providerId) + .Returns(provider); + + sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) + .ReturnsNull(); + + var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_NoOrganization_ServerError( + Guid providerId, + Guid providerOrganizationId, + UpdateProviderOrganizationRequestBody requestBody, + Provider provider, + ProviderOrganization providerOrganization, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(providerId) + .Returns(provider); + + sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) + .Returns(providerOrganization); + + sutProvider.GetDependency().GetByIdAsync(providerOrganization.OrganizationId) + .ReturnsNull(); + + var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_NoContent( + Guid providerId, + Guid providerOrganizationId, + UpdateProviderOrganizationRequestBody requestBody, + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(providerId) + .Returns(provider); + + sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) + .Returns(providerOrganization); + + sutProvider.GetDependency().GetByIdAsync(providerOrganization.OrganizationId) + .Returns(organization); + + var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody); + + await sutProvider.GetDependency().Received(1) + .AssignSeatsToClientOrganization( + provider, + organization, + requestBody.AssignedSeats); + + Assert.IsType(result); + } +} diff --git a/test/Core.Test/Billing/Commands/AssignSeatsToClientOrganizationCommandTests.cs b/test/Core.Test/Billing/Commands/AssignSeatsToClientOrganizationCommandTests.cs new file mode 100644 index 000000000000..918b7c47a23f --- /dev/null +++ b/test/Core.Test/Billing/Commands/AssignSeatsToClientOrganizationCommandTests.cs @@ -0,0 +1,339 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing; +using Bit.Core.Billing.Commands.Implementations; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Queries; +using Bit.Core.Billing.Repositories; +using Bit.Core.Enums; +using Bit.Core.Models.StaticStore; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +using static Bit.Core.Test.Billing.Utilities; + +namespace Bit.Core.Test.Billing.Commands; + +[SutProviderCustomize] +public class AssignSeatsToClientOrganizationCommandTests +{ + [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, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsMonthly; + + organization.Seats = seats; + + await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); + + await sutProvider.GetDependency().DidNotReceive().GetByProviderId(provider.Id); + } + + [Theory, BitAutoData] + public async Task AssignSeatsToClientOrganization_OrganizationPlanTypeDoesNotSupportConsolidatedBilling_ContactSupport( + Provider provider, + Organization organization, + int seats, + SutProvider sutProvider) + { + organization.PlanType = PlanType.FamiliesAnnually; + + await ThrowsContactSupportAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); + } + + [Theory, BitAutoData] + public async Task AssignSeatsToClientOrganization_ProviderPlanIsNotConfigured_ContactSupport( + Provider provider, + Organization organization, + int seats, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsMonthly; + + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(new List + { + new () + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + ProviderId = provider.Id + } + }); + + await ThrowsContactSupportAsync(() => 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 + } + }; + + var providerPlan = providerPlans.First(); + + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + + // 50 seats currently assigned with a seat minimum of 100 + sutProvider.GetDependency().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(50); + + 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)); + } + + [Theory, BitAutoData] + public async Task AssignSeatsToClientOrganization_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() + { + 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 + } + }; + + var providerPlan = providerPlans.First(); + + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + + // 95 seats currently assigned with a seat minimum of 100 + sutProvider.GetDependency().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(95); + + await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); + + // 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); + + 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( + 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, + // 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().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(110); + + 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().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(110); + + 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)); + } +} diff --git a/test/Core.Test/Billing/Queries/ProviderBillingQueriesTests.cs b/test/Core.Test/Billing/Queries/ProviderBillingQueriesTests.cs index 0962ed32b10b..534444ba94b1 100644 --- a/test/Core.Test/Billing/Queries/ProviderBillingQueriesTests.cs +++ b/test/Core.Test/Billing/Queries/ProviderBillingQueriesTests.cs @@ -87,7 +87,8 @@ public class ProviderBillingQueriesTests ProviderId = providerId, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 100, - PurchasedSeats = 0 + PurchasedSeats = 0, + AllocatedSeats = 0 }; var teamsPlan = new ProviderPlan @@ -96,7 +97,8 @@ public class ProviderBillingQueriesTests ProviderId = providerId, PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, - PurchasedSeats = 10 + PurchasedSeats = 10, + AllocatedSeats = 60 }; var providerPlans = new List @@ -145,6 +147,7 @@ void Compare(ProviderPlan providerPlan, ConfiguredProviderPlan configuredProvide Assert.Equal(providerPlan.ProviderId, configuredProviderPlan.ProviderId); Assert.Equal(providerPlan.SeatMinimum!.Value, configuredProviderPlan.SeatMinimum); Assert.Equal(providerPlan.PurchasedSeats!.Value, configuredProviderPlan.PurchasedSeats); + Assert.Equal(providerPlan.AllocatedSeats!.Value, configuredProviderPlan.AssignedSeats); } } #endregion