diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 65e41ab586..757d6510f1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -35,7 +35,6 @@ public class ProviderBillingService( IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, - IPaymentService paymentService, IPricingClient pricingClient, IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderOrganizationRepository providerOrganizationRepository, @@ -148,36 +147,29 @@ public class ProviderBillingService( public async Task ChangePlan(ChangeProviderPlanCommand command) { - var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId); + var (provider, providerPlanId, newPlanType) = command; - if (plan == null) + var providerPlan = await providerPlanRepository.GetByIdAsync(providerPlanId); + + if (providerPlan == null) { throw new BadRequestException("Provider plan not found."); } - if (plan.PlanType == command.NewPlan) + if (providerPlan.PlanType == newPlanType) { return; } - var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType); - var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan); + var subscription = await subscriberService.GetSubscriptionOrThrow(provider); - plan.PlanType = command.NewPlan; - await providerPlanRepository.ReplaceAsync(plan); + var oldPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType); + var newPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, newPlanType); - Subscription subscription; - try - { - subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId); - } - catch (InvalidOperationException) - { - throw new ConflictException("Subscription not found."); - } + providerPlan.PlanType = newPlanType; + await providerPlanRepository.ReplaceAsync(providerPlan); - var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => - x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId); + var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => x.Price.Id == oldPriceId); var updateOptions = new SubscriptionUpdateOptions { @@ -185,7 +177,7 @@ public class ProviderBillingService( [ new SubscriptionItemOptions { - Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId, + Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity }, new SubscriptionItemOptions @@ -196,12 +188,14 @@ public class ProviderBillingService( ] }; - await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions); + await stripeAdapter.SubscriptionUpdateAsync(provider.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); + var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); + + var newPlan = await pricingClient.GetPlanOrThrow(newPlanType); foreach (var providerOrganization in providerOrganizations) { @@ -210,8 +204,8 @@ public class ProviderBillingService( { throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); } - organization.PlanType = command.NewPlan; - organization.Plan = newPlanConfiguration.Name; + organization.PlanType = newPlanType; + organization.Plan = newPlan.Name; await organizationRepository.ReplaceAsync(organization); } } @@ -405,7 +399,7 @@ public class ProviderBillingService( var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment; - var update = CurrySeatScalingUpdate( + var scaleQuantityTo = CurrySeatScalingUpdate( provider, providerPlan, newlyAssignedSeatTotal); @@ -428,9 +422,7 @@ public class ProviderBillingService( else if (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) { - await update( - seatMinimum, - newlyAssignedSeatTotal); + await scaleQuantityTo(newlyAssignedSeatTotal); } /* * Above the limit => Above the limit: @@ -439,9 +431,7 @@ public class ProviderBillingService( else if (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum) { - await update( - currentlyAssignedSeatTotal, - newlyAssignedSeatTotal); + await scaleQuantityTo(newlyAssignedSeatTotal); } /* * Above the limit => Below the limit: @@ -450,9 +440,7 @@ public class ProviderBillingService( else if (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal <= seatMinimum) { - await update( - currentlyAssignedSeatTotal, - seatMinimum); + await scaleQuantityTo(seatMinimum); } } @@ -586,9 +574,11 @@ public class ProviderBillingService( throw new BillingException(); } + var priceId = ProviderPriceAdapter.GetActivePriceId(provider, providerPlan.PlanType); + subscriptionItemOptionsList.Add(new SubscriptionItemOptions { - Price = plan.PasswordManager.StripeProviderPortalSeatPlanId, + Price = priceId, Quantity = providerPlan.SeatMinimum }); } @@ -654,43 +644,37 @@ public class ProviderBillingService( public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) { - if (command.Configuration.Any(x => x.SeatsMinimum < 0)) + var (provider, updatedPlanConfigurations) = command; + + if (updatedPlanConfigurations.Any(x => x.SeatsMinimum < 0)) { throw new BadRequestException("Provider seat minimums must be at least 0."); } - Subscription subscription; - try - { - subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id); - } - catch (InvalidOperationException) - { - throw new ConflictException("Subscription not found."); - } + var subscription = await subscriberService.GetSubscriptionOrThrow(provider); var subscriptionItemOptionsList = new List(); - var providerPlans = await providerPlanRepository.GetByProviderId(command.Id); + var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); - foreach (var newPlanConfiguration in command.Configuration) + foreach (var updatedPlanConfiguration in updatedPlanConfigurations) { + var (updatedPlanType, updatedSeatMinimum) = updatedPlanConfiguration; + var providerPlan = - providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan); + providerPlans.Single(providerPlan => providerPlan.PlanType == updatedPlanType); - if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum) + if (providerPlan.SeatMinimum != updatedSeatMinimum) { - var newPlan = await pricingClient.GetPlanOrThrow(newPlanConfiguration.Plan); - - var priceId = newPlan.PasswordManager.StripeProviderPortalSeatPlanId; + var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, updatedPlanType); var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId); if (providerPlan.PurchasedSeats == 0) { - if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum) + if (providerPlan.AllocatedSeats > updatedSeatMinimum) { - providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum; + providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - updatedSeatMinimum; subscriptionItemOptionsList.Add(new SubscriptionItemOptions { @@ -705,7 +689,7 @@ public class ProviderBillingService( { Id = subscriptionItem.Id, Price = priceId, - Quantity = newPlanConfiguration.SeatsMinimum + Quantity = updatedSeatMinimum }); } } @@ -713,9 +697,9 @@ public class ProviderBillingService( { var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats; - if (newPlanConfiguration.SeatsMinimum <= totalSeats) + if (updatedSeatMinimum <= totalSeats) { - providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum; + providerPlan.PurchasedSeats = totalSeats - updatedSeatMinimum; } else { @@ -724,12 +708,12 @@ public class ProviderBillingService( { Id = subscriptionItem.Id, Price = priceId, - Quantity = newPlanConfiguration.SeatsMinimum + Quantity = updatedSeatMinimum }); } } - providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum; + providerPlan.SeatMinimum = updatedSeatMinimum; await providerPlanRepository.ReplaceAsync(providerPlan); } @@ -737,23 +721,33 @@ public class ProviderBillingService( if (subscriptionItemOptionsList.Count > 0) { - await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, + await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList }); } } - private Func CurrySeatScalingUpdate( + private Func CurrySeatScalingUpdate( Provider provider, ProviderPlan providerPlan, - int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) => + int newlyAssignedSeats) => async newlySubscribedSeats => { - var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); + var subscription = await subscriberService.GetSubscriptionOrThrow(provider); - await paymentService.AdjustSeats( - provider, - plan, - currentlySubscribedSeats, - newlySubscribedSeats); + var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType); + + var item = subscription.Items.First(item => item.Price.Id == priceId); + + await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions + { + Items = [ + new SubscriptionItemOptions + { + Id = item.Id, + Price = priceId, + Quantity = newlySubscribedSeats + } + ] + }); var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum ? newlySubscribedSeats - providerPlan.SeatMinimum diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs new file mode 100644 index 0000000000..4cc0711ec9 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs @@ -0,0 +1,133 @@ +// ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault +#nullable enable +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing; +using Bit.Core.Billing.Enums; +using Stripe; + +namespace Bit.Commercial.Core.Billing; + +public static class ProviderPriceAdapter +{ + public static class MSP + { + public static class Active + { + public const string Enterprise = "provider-portal-enterprise-monthly-2025"; + public const string Teams = "provider-portal-teams-monthly-2025"; + } + + public static class Legacy + { + public const string Enterprise = "password-manager-provider-portal-enterprise-monthly-2024"; + public const string Teams = "password-manager-provider-portal-teams-monthly-2024"; + public static readonly List List = [Enterprise, Teams]; + } + } + + public static class BusinessUnit + { + public static class Active + { + public const string Annually = "business-unit-portal-enterprise-annually-2025"; + public const string Monthly = "business-unit-portal-enterprise-monthly-2025"; + } + + public static class Legacy + { + public const string Annually = "password-manager-provider-portal-enterprise-annually-2024"; + public const string Monthly = "password-manager-provider-portal-enterprise-monthly-2024"; + public static readonly List List = [Annually, Monthly]; + } + } + + /// + /// Uses the 's and to determine + /// whether the is on active or legacy pricing and then returns a Stripe price ID for the provided + /// based on that determination. + /// + /// The provider to get the Stripe price ID for. + /// The provider's subscription. + /// The plan type correlating to the desired Stripe price ID. + /// A Stripe ID. + /// Thrown when the provider's type is not or . + /// Thrown when the provided does not relate to a Stripe price ID. + public static string GetPriceId( + Provider provider, + Subscription subscription, + PlanType planType) + { + var priceIds = subscription.Items.Select(item => item.Price.Id); + + var invalidPlanType = + new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe"); + + return provider.Type switch + { + ProviderType.Msp => MSP.Legacy.List.Intersect(priceIds).Any() + ? planType switch + { + PlanType.TeamsMonthly => MSP.Legacy.Teams, + PlanType.EnterpriseMonthly => MSP.Legacy.Enterprise, + _ => throw invalidPlanType + } + : planType switch + { + PlanType.TeamsMonthly => MSP.Active.Teams, + PlanType.EnterpriseMonthly => MSP.Active.Enterprise, + _ => throw invalidPlanType + }, + ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any() + ? planType switch + { + PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually, + PlanType.EnterpriseMonthly => BusinessUnit.Legacy.Monthly, + _ => throw invalidPlanType + } + : planType switch + { + PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually, + PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly, + _ => throw invalidPlanType + }, + _ => throw new BillingException( + $"ProviderType {provider.Type} does not have any associated provider price IDs") + }; + } + + /// + /// Uses the 's to return the active Stripe price ID for the provided + /// . + /// + /// The provider to get the Stripe price ID for. + /// The plan type correlating to the desired Stripe price ID. + /// A Stripe ID. + /// Thrown when the provider's type is not or . + /// Thrown when the provided does not relate to a Stripe price ID. + public static string GetActivePriceId( + Provider provider, + PlanType planType) + { + var invalidPlanType = + new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe"); + + return provider.Type switch + { + ProviderType.Msp => planType switch + { + PlanType.TeamsMonthly => MSP.Active.Teams, + PlanType.EnterpriseMonthly => MSP.Active.Enterprise, + _ => throw invalidPlanType + }, + ProviderType.MultiOrganizationEnterprise => planType switch + { + PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually, + PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly, + _ => throw invalidPlanType + }, + _ => throw new BillingException( + $"ProviderType {provider.Type} does not have any associated provider price IDs") + }; + } +} diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index 71a150a546..ab1000d631 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -4,6 +4,7 @@ using Bit.Commercial.Core.Billing; using Bit.Commercial.Core.Billing.Models; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; @@ -115,6 +116,8 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange + provider.Type = ProviderType.MultiOrganizationEnterprise; + var providerPlanRepository = sutProvider.GetDependency(); var existingPlan = new ProviderPlan { @@ -132,10 +135,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetPlanOrThrow(existingPlan.PlanType) .Returns(StaticStore.GetPlan(existingPlan.PlanType)); - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.ProviderSubscriptionGetAsync( - Arg.Is(provider.GatewaySubscriptionId), - Arg.Is(provider.Id)) + sutProvider.GetDependency().GetSubscriptionOrThrow(provider) .Returns(new Subscription { Id = provider.GatewaySubscriptionId, @@ -158,7 +158,7 @@ public class ProviderBillingServiceTests }); var command = - new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId); + new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly); sutProvider.GetDependency().GetPlanOrThrow(command.NewPlan) .Returns(StaticStore.GetPlan(command.NewPlan)); @@ -170,6 +170,8 @@ public class ProviderBillingServiceTests await providerPlanRepository.Received(1) .ReplaceAsync(Arg.Is(p => p.PlanType == PlanType.EnterpriseMonthly)); + var stripeAdapter = sutProvider.GetDependency(); + await stripeAdapter.Received(1) .SubscriptionUpdateAsync( Arg.Is(provider.GatewaySubscriptionId), @@ -405,6 +407,23 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } }, + new SubscriptionItem + { + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise } + } + ] + } + }; + + sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); + // 50 seats currently assigned with a seat minimum of 100 var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); @@ -427,11 +446,9 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().AdjustSeats( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync( + Arg.Any(), + Arg.Any()); await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( pPlan => pPlan.AllocatedSeats == 60)); @@ -474,6 +491,23 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } }, + new SubscriptionItem + { + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise } + } + ] + } + }; + + sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); + // 95 seats currently assigned with a seat minimum of 100 var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); @@ -496,11 +530,12 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 95 current + 10 seat scale = 105 seats, 5 above the minimum - await sutProvider.GetDependency().Received(1).AdjustSeats( - provider, - StaticStore.GetPlan(providerPlan.PlanType), - providerPlan.SeatMinimum!.Value, - 105); + await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + provider.GatewaySubscriptionId, + Arg.Is( + options => + options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams && + options.Items.First().Quantity == 105)); // 105 total seats - 100 minimum = 5 purchased seats await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( @@ -544,6 +579,23 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } }, + new SubscriptionItem + { + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise } + } + ] + } + }; + + sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); + // 110 seats currently assigned with a seat minimum of 100 var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); @@ -566,11 +618,12 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 110 current + 10 seat scale up = 120 seats - await sutProvider.GetDependency().Received(1).AdjustSeats( - provider, - StaticStore.GetPlan(providerPlan.PlanType), - 110, - 120); + await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + provider.GatewaySubscriptionId, + Arg.Is( + options => + options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams && + options.Items.First().Quantity == 120)); // 120 total seats - 100 seat minimum = 20 purchased seats await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( @@ -614,6 +667,23 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } }, + new SubscriptionItem + { + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise } + } + ] + } + }; + + sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); + // 110 seats currently assigned with a seat minimum of 100 var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); @@ -636,11 +706,12 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30); // 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum. - await sutProvider.GetDependency().Received(1).AdjustSeats( - provider, - StaticStore.GetPlan(providerPlan.PlanType), - 110, - providerPlan.SeatMinimum!.Value); + await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + provider.GatewaySubscriptionId, + Arg.Is( + options => + options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams && + options.Items.First().Quantity == providerPlan.SeatMinimum!.Value)); // Being below the seat minimum means no purchased seats. await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( @@ -977,6 +1048,7 @@ public class ProviderBillingServiceTests SutProvider sutProvider, Provider provider) { + provider.Type = ProviderType.Msp; provider.GatewaySubscriptionId = null; var customer = new Customer @@ -1020,9 +1092,6 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); - var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); - var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; sutProvider.GetDependency() @@ -1045,9 +1114,9 @@ public class ProviderBillingServiceTests sub.Customer == "customer_id" && sub.DaysUntilDue == 30 && sub.Items.Count == 2 && - sub.Items.ElementAt(0).Price == teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId && + sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams && sub.Items.ElementAt(0).Quantity == 100 && - sub.Items.ElementAt(1).Price == enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId && + sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise && sub.Items.ElementAt(1).Quantity == 100 && sub.Metadata["providerId"] == provider.Id.ToString() && sub.OffSession == true && @@ -1069,8 +1138,7 @@ public class ProviderBillingServiceTests { // Arrange var command = new UpdateProviderSeatMinimumsCommand( - provider.Id, - provider.GatewaySubscriptionId, + provider, [ (PlanType.TeamsMonthly, -10), (PlanType.EnterpriseMonthly, 50) @@ -1089,6 +1157,8 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange + provider.Type = ProviderType.Msp; + var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); @@ -1118,9 +1188,7 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.ProviderSubscriptionGetAsync( - provider.GatewaySubscriptionId, - provider.Id).Returns(subscription); + sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); var providerPlans = new List { @@ -1137,8 +1205,7 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( - provider.Id, - provider.GatewaySubscriptionId, + provider, [ (PlanType.EnterpriseMonthly, 30), (PlanType.TeamsMonthly, 20) @@ -1170,6 +1237,8 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange + provider.Type = ProviderType.Msp; + var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); @@ -1199,7 +1268,7 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription); + sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); var providerPlans = new List { @@ -1216,8 +1285,7 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( - provider.Id, - provider.GatewaySubscriptionId, + provider, [ (PlanType.EnterpriseMonthly, 70), (PlanType.TeamsMonthly, 50) @@ -1249,6 +1317,8 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange + provider.Type = ProviderType.Msp; + var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); @@ -1278,7 +1348,7 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription); + sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); var providerPlans = new List { @@ -1295,8 +1365,7 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( - provider.Id, - provider.GatewaySubscriptionId, + provider, [ (PlanType.EnterpriseMonthly, 60), (PlanType.TeamsMonthly, 60) @@ -1322,6 +1391,8 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange + provider.Type = ProviderType.Msp; + var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); @@ -1351,7 +1422,7 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription); + sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); var providerPlans = new List { @@ -1368,8 +1439,7 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( - provider.Id, - provider.GatewaySubscriptionId, + provider, [ (PlanType.EnterpriseMonthly, 80), (PlanType.TeamsMonthly, 80) @@ -1401,6 +1471,8 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange + provider.Type = ProviderType.Msp; + var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); @@ -1430,7 +1502,7 @@ public class ProviderBillingServiceTests } }; - stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription); + sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); var providerPlans = new List { @@ -1447,8 +1519,7 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( - provider.Id, - provider.GatewaySubscriptionId, + provider, [ (PlanType.EnterpriseMonthly, 70), (PlanType.TeamsMonthly, 30) diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs new file mode 100644 index 0000000000..4fce78c05a --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs @@ -0,0 +1,151 @@ +using Bit.Commercial.Core.Billing; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing.Enums; +using Stripe; +using Xunit; + +namespace Bit.Commercial.Core.Test.Billing; + +public class ProviderPriceAdapterTests +{ + [Theory] + [InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)] + [InlineData("password-manager-provider-portal-teams-monthly-2024", PlanType.TeamsMonthly)] + public void GetPriceId_MSP_Legacy_Succeeds(string priceId, PlanType planType) + { + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.Msp + }; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = priceId } } + ] + } + }; + + var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType); + + Assert.Equal(result, priceId); + } + + [Theory] + [InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)] + [InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)] + public void GetPriceId_MSP_Active_Succeeds(string priceId, PlanType planType) + { + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.Msp + }; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = priceId } } + ] + } + }; + + var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType); + + Assert.Equal(result, priceId); + } + + [Theory] + [InlineData("password-manager-provider-portal-enterprise-annually-2024", PlanType.EnterpriseAnnually)] + [InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)] + public void GetPriceId_BusinessUnit_Legacy_Succeeds(string priceId, PlanType planType) + { + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.MultiOrganizationEnterprise + }; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = priceId } } + ] + } + }; + + var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType); + + Assert.Equal(result, priceId); + } + + [Theory] + [InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)] + [InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)] + public void GetPriceId_BusinessUnit_Active_Succeeds(string priceId, PlanType planType) + { + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.MultiOrganizationEnterprise + }; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = priceId } } + ] + } + }; + + var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType); + + Assert.Equal(result, priceId); + } + + [Theory] + [InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)] + [InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)] + public void GetActivePriceId_MSP_Succeeds(string priceId, PlanType planType) + { + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.Msp + }; + + var result = ProviderPriceAdapter.GetActivePriceId(provider, planType); + + Assert.Equal(result, priceId); + } + + [Theory] + [InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)] + [InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)] + public void GetActivePriceId_BusinessUnit_Succeeds(string priceId, PlanType planType) + { + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.MultiOrganizationEnterprise + }; + + var result = ProviderPriceAdapter.GetActivePriceId(provider, planType); + + Assert.Equal(result, priceId); + } +} diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index c38bb64419..0b1e4035df 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -300,8 +300,7 @@ public class ProvidersController : Controller { case ProviderType.Msp: var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand( - provider.Id, - provider.GatewaySubscriptionId, + provider, [ (Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum), (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum) @@ -314,15 +313,14 @@ public class ProvidersController : Controller // 1. Change the plan and take over any old values. var changeMoePlanCommand = new ChangeProviderPlanCommand( + provider, existingMoePlan.Id, - model.Plan!.Value, - provider.GatewaySubscriptionId); + model.Plan!.Value); await _providerBillingService.ChangePlan(changeMoePlanCommand); // 2. Update the seat minimums. var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand( - provider.Id, - provider.GatewaySubscriptionId, + provider, [ (Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value) ]); diff --git a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs index b5c4383556..384cfca1d1 100644 --- a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs @@ -309,8 +309,7 @@ public class ProviderMigrator( .SeatMinimum ?? 0; var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand( - provider.Id, - provider.GatewaySubscriptionId, + provider, [ (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum), (Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum) diff --git a/src/Core/Billing/Models/StaticStore/Plan.cs b/src/Core/Billing/Models/StaticStore/Plan.cs index 5dbcd7ddc4..17aa78aa06 100644 --- a/src/Core/Billing/Models/StaticStore/Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plan.cs @@ -75,6 +75,7 @@ public abstract record Plan // Seats public string StripePlanId { get; init; } public string StripeSeatPlanId { get; init; } + [Obsolete("No longer used to retrieve a provider's price ID. Use ProviderPriceAdapter instead.")] public string StripeProviderPortalSeatPlanId { get; init; } public decimal BasePrice { get; init; } public decimal SeatPrice { get; init; } diff --git a/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs b/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs index 3e8fffdd11..385782c8ad 100644 --- a/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs +++ b/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs @@ -1,8 +1,9 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Enums; namespace Bit.Core.Billing.Services.Contracts; public record ChangeProviderPlanCommand( + Provider Provider, Guid ProviderPlanId, - PlanType NewPlan, - string GatewaySubscriptionId); + PlanType NewPlan); diff --git a/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs b/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs index 86a596ffb6..2d2535b60a 100644 --- a/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs +++ b/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs @@ -1,10 +1,10 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Enums; namespace Bit.Core.Billing.Services.Contracts; -/// The ID of the provider to update the seat minimums for. +/// The provider to update the seat minimums for. /// The new seat minimums for the provider. public record UpdateProviderSeatMinimumsCommand( - Guid Id, - string GatewaySubscriptionId, + Provider Provider, IReadOnlyCollection<(PlanType Plan, int SeatsMinimum)> Configuration); diff --git a/src/Core/Models/Business/ProviderSubscriptionUpdate.cs b/src/Core/Models/Business/ProviderSubscriptionUpdate.cs deleted file mode 100644 index 1fd833ca1f..0000000000 --- a/src/Core/Models/Business/ProviderSubscriptionUpdate.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Bit.Core.Billing; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Extensions; -using Stripe; -using Plan = Bit.Core.Models.StaticStore.Plan; - -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( - Plan plan, - int previouslyPurchasedSeats, - int newlyPurchasedSeats) - { - if (!plan.Type.SupportsConsolidatedBilling()) - { - throw new BillingException( - message: $"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing"); - } - - _planId = plan.PasswordManager.StripeProviderPortalSeatPlanId; - _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/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index e3495c0e65..bd7efdbad4 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Models.Api.Requests.Organizations; @@ -25,11 +24,6 @@ public interface IPaymentService int? newlyPurchasedAdditionalSecretsManagerServiceAccounts, int newlyPurchasedAdditionalStorage); Task AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats); - Task AdjustSeats( - Provider provider, - Plan plan, - int currentlySubscribedSeats, - int newlySubscribedSeats); Task AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index cdcd14ca90..d8889bca26 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; @@ -251,18 +250,6 @@ public class StripePaymentService : IPaymentService public Task AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) => FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats)); - public Task AdjustSeats( - Provider provider, - StaticStore.Plan plan, - int currentlySubscribedSeats, - int newlySubscribedSeats) - => FinalizeSubscriptionChangeAsync( - provider, - new ProviderSubscriptionUpdate( - plan, - currentlySubscribedSeats, - newlySubscribedSeats)); - public Task AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) => FinalizeSubscriptionChangeAsync( organization,