diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index ce0c0c9335..d2acdac079 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -5,12 +5,12 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; using Stripe; namespace Bit.Commercial.Core.AdminConsole.Providers; @@ -27,6 +27,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv private readonly IProviderBillingService _providerBillingService; private readonly ISubscriberService _subscriberService; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; + private readonly IPricingClient _pricingClient; public RemoveOrganizationFromProviderCommand( IEventService eventService, @@ -38,7 +39,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv IFeatureService featureService, IProviderBillingService providerBillingService, ISubscriberService subscriberService, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IPricingClient pricingClient) { _eventService = eventService; _mailService = mailService; @@ -50,6 +52,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv _providerBillingService = providerBillingService; _subscriberService = subscriberService; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; + _pricingClient = pricingClient; } public async Task RemoveOrganizationFromProvider( @@ -110,7 +113,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv Email = organization.BillingEmail }); - var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager; + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); var subscriptionCreateOptions = new SubscriptionCreateOptions { @@ -124,7 +127,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv }, OffSession = true, ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, - Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }] + Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }] }; var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 864466ad45..799b57dc5a 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -50,6 +51,7 @@ public class ProviderService : IProviderService private readonly IDataProtectorTokenFactory _providerDeleteTokenDataFactory; private readonly IApplicationCacheService _applicationCacheService; private readonly IProviderBillingService _providerBillingService; + private readonly IPricingClient _pricingClient; public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, @@ -58,7 +60,7 @@ public class ProviderService : IProviderService IOrganizationRepository organizationRepository, GlobalSettings globalSettings, ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, IDataProtectorTokenFactory providerDeleteTokenDataFactory, - IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService) + IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -77,6 +79,7 @@ public class ProviderService : IProviderService _providerDeleteTokenDataFactory = providerDeleteTokenDataFactory; _applicationCacheService = applicationCacheService; _providerBillingService = providerBillingService; + _pricingClient = pricingClient; } public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null) @@ -452,30 +455,31 @@ public class ProviderService : IProviderService if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { - var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, - GetStripeSeatPlanId(organization.PlanType)); + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + + var subscriptionItem = await GetSubscriptionItemAsync( + organization.GatewaySubscriptionId, + plan.PasswordManager.StripeSeatPlanId); + var extractedPlanType = PlanTypeMappings(organization); + var extractedPlan = await _pricingClient.GetPlanOrThrow(extractedPlanType); + if (subscriptionItem != null) { - await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization); + await UpdateSubscriptionAsync(subscriptionItem, extractedPlan.PasswordManager.StripeSeatPlanId, organization); } } await _organizationRepository.UpsertAsync(organization); } - private async Task GetSubscriptionItemAsync(string subscriptionId, string oldPlanId) + private async Task GetSubscriptionItemAsync(string subscriptionId, string oldPlanId) { var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId); return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId); } - private static string GetStripeSeatPlanId(PlanType planType) - { - return StaticStore.GetPlan(planType).PasswordManager.StripeSeatPlanId; - } - - private async Task UpdateSubscriptionAsync(Stripe.SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization) + private async Task UpdateSubscriptionAsync(SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization) { try { diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 7b10793283..b637cf37ef 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -10,6 +10,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; @@ -32,6 +33,7 @@ public class ProviderBillingService( ILogger logger, IOrganizationRepository organizationRepository, IPaymentService paymentService, + IPricingClient pricingClient, IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, @@ -77,8 +79,7 @@ public class ProviderBillingService( var managedPlanType = await GetManagedPlanTypeAsync(provider, organization); - // TODO: Replace with PricingClient - var plan = StaticStore.GetPlan(managedPlanType); + var plan = await pricingClient.GetPlanOrThrow(managedPlanType); organization.Plan = plan.Name; organization.PlanType = plan.Type; organization.MaxCollections = plan.PasswordManager.MaxCollections; @@ -154,7 +155,8 @@ public class ProviderBillingService( return; } - var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType); + var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType); + var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan); plan.PlanType = command.NewPlan; await providerPlanRepository.ReplaceAsync(plan); @@ -178,7 +180,7 @@ public class ProviderBillingService( [ new SubscriptionItemOptions { - Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId, + Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId, Quantity = oldSubscriptionItem!.Quantity }, new SubscriptionItemOptions @@ -204,7 +206,7 @@ public class ProviderBillingService( throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); } organization.PlanType = command.NewPlan; - organization.Plan = StaticStore.GetPlan(command.NewPlan).Name; + organization.Plan = newPlanConfiguration.Name; await organizationRepository.ReplaceAsync(organization); } } @@ -347,7 +349,7 @@ public class ProviderBillingService( { var (organization, _) = pair; - var planName = DerivePlanName(provider, organization); + var planName = await DerivePlanName(provider, organization); var addable = new AddableOrganization( organization.Id, @@ -368,7 +370,7 @@ public class ProviderBillingService( return addable with { Disabled = requiresPurchase }; })); - string DerivePlanName(Provider localProvider, Organization localOrganization) + async Task DerivePlanName(Provider localProvider, Organization localOrganization) { if (localProvider.Type == ProviderType.Msp) { @@ -380,8 +382,7 @@ public class ProviderBillingService( }; } - // TODO: Replace with PricingClient - var plan = StaticStore.GetPlan(localOrganization.PlanType); + var plan = await pricingClient.GetPlanOrThrow(localOrganization.PlanType); return plan.Name; } } @@ -568,7 +569,7 @@ public class ProviderBillingService( foreach (var providerPlan in providerPlans) { - var plan = StaticStore.GetPlan(providerPlan.PlanType); + var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); if (!providerPlan.IsConfigured()) { @@ -652,8 +653,10 @@ public class ProviderBillingService( if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum) { - var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager - .StripeProviderPortalSeatPlanId; + var newPlan = await pricingClient.GetPlanOrThrow(newPlanConfiguration.Plan); + + var priceId = newPlan.PasswordManager.StripeProviderPortalSeatPlanId; + var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId); if (providerPlan.PurchasedSeats == 0) @@ -717,7 +720,7 @@ public class ProviderBillingService( ProviderPlan providerPlan, int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) => { - var plan = StaticStore.GetPlan(providerPlan.PlanType); + var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); await paymentService.AdjustSeats( provider, @@ -741,7 +744,7 @@ public class ProviderBillingService( var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id); - var plan = StaticStore.GetPlan(planType); + var plan = await pricingClient.GetPlanOrThrow(planType); return providerOrganizations .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs index ee7bc398fe..d9a7d4a2ce 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs @@ -28,6 +28,7 @@ public class MaxProjectsQuery : IMaxProjectsQuery throw new NotFoundException(); } + // TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122 var plan = StaticStore.GetPlan(org.PlanType); if (plan?.SecretsManager == null) { diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index f45ab75046..2debd521a5 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -205,6 +206,8 @@ public class RemoveOrganizationFromProviderCommandTests var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( providerOrganization.OrganizationId, [], diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index 2883c9d7e3..d2d82f47de 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -550,8 +551,14 @@ public class ProviderServiceTests organization.PlanType = PlanType.EnterpriseMonthly; organization.Plan = "Enterprise (Monthly)"; + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + var expectedPlanType = PlanType.EnterpriseMonthly2020; + sutProvider.GetDependency().GetPlanOrThrow(expectedPlanType) + .Returns(StaticStore.GetPlan(expectedPlanType)); + var expectedPlanId = "2020-enterprise-org-seat-monthly"; sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index 3739603a2d..2fbd09a213 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; @@ -128,6 +129,9 @@ public class ProviderBillingServiceTests .GetByIdAsync(Arg.Is(p => p == providerPlanId)) .Returns(existingPlan); + sutProvider.GetDependency().GetPlanOrThrow(existingPlan.PlanType) + .Returns(StaticStore.GetPlan(existingPlan.PlanType)); + var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.ProviderSubscriptionGetAsync( Arg.Is(provider.GatewaySubscriptionId), @@ -156,6 +160,9 @@ public class ProviderBillingServiceTests var command = new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId); + sutProvider.GetDependency().GetPlanOrThrow(command.NewPlan) + .Returns(StaticStore.GetPlan(command.NewPlan)); + // Act await sutProvider.Sut.ChangePlan(command); @@ -390,6 +397,12 @@ public class ProviderBillingServiceTests } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); // 50 seats currently assigned with a seat minimum of 100 @@ -451,6 +464,12 @@ public class ProviderBillingServiceTests } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + var providerPlan = providerPlans.First(); sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); @@ -515,6 +534,12 @@ public class ProviderBillingServiceTests } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + var providerPlan = providerPlans.First(); sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); @@ -579,6 +604,12 @@ public class ProviderBillingServiceTests } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + var providerPlan = providerPlans.First(); sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); @@ -636,6 +667,8 @@ public class ProviderBillingServiceTests } ]); + sutProvider.GetDependency().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType)); + sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ new ProviderOrganizationOrganizationDetails @@ -672,6 +705,8 @@ public class ProviderBillingServiceTests } ]); + sutProvider.GetDependency().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType)); + sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ new ProviderOrganizationOrganizationDetails @@ -856,6 +891,9 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); + sutProvider.GetDependency().GetPlanOrThrow(PlanType.EnterpriseMonthly) + .Returns(StaticStore.GetPlan(PlanType.EnterpriseMonthly)); + await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await sutProvider.GetDependency() @@ -881,6 +919,9 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); + sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly) + .Returns(StaticStore.GetPlan(PlanType.TeamsMonthly)); + await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await sutProvider.GetDependency() @@ -923,6 +964,12 @@ public class ProviderBillingServiceTests } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); @@ -968,6 +1015,12 @@ public class ProviderBillingServiceTests } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); @@ -1066,6 +1119,12 @@ public class ProviderBillingServiceTests new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 25 } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( @@ -1139,6 +1198,12 @@ public class ProviderBillingServiceTests new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 15 } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( @@ -1212,6 +1277,12 @@ public class ProviderBillingServiceTests new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( @@ -1279,6 +1350,12 @@ public class ProviderBillingServiceTests new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( @@ -1352,6 +1429,12 @@ public class ProviderBillingServiceTests new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0 } }; + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var command = new UpdateProviderSeatMinimumsCommand( diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 60a5a39612..fdb4961d9b 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -10,6 +10,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; @@ -56,8 +57,8 @@ public class OrganizationsController : Controller private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; private readonly IProviderBillingService _providerBillingService; - private readonly IFeatureService _featureService; private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; + private readonly IPricingClient _pricingClient; public OrganizationsController( IOrganizationService organizationService, @@ -84,8 +85,8 @@ public class OrganizationsController : Controller IProviderOrganizationRepository providerOrganizationRepository, IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IProviderBillingService providerBillingService, - IFeatureService featureService, - IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand) + IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, + IPricingClient pricingClient) { _organizationService = organizationService; _organizationRepository = organizationRepository; @@ -111,8 +112,8 @@ public class OrganizationsController : Controller _providerOrganizationRepository = providerOrganizationRepository; _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; _providerBillingService = providerBillingService; - _featureService = featureService; _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; + _pricingClient = pricingClient; } [RequirePermission(Permission.Org_List_View)] @@ -212,6 +213,8 @@ public class OrganizationsController : Controller ? await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) : -1; + var plans = await _pricingClient.ListPlans(); + return View(new OrganizationEditModel( organization, provider, @@ -224,6 +227,7 @@ public class OrganizationsController : Controller billingHistoryInfo, billingSyncConnection, _globalSettings, + plans, secrets, projects, serviceAccounts, @@ -253,8 +257,9 @@ public class OrganizationsController : Controller UpdateOrganization(organization, model); - if (organization.UseSecretsManager && - !StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager) + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + + if (organization.UseSecretsManager && !plan.SupportsSecretsManager) { TempData["Error"] = "Plan does not support Secrets Manager"; return RedirectToAction("Edit", new { id }); diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index be191ddb8d..729b4f7990 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Models.StaticStore; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; @@ -17,6 +18,8 @@ namespace Bit.Admin.AdminConsole.Models; public class OrganizationEditModel : OrganizationViewModel { + private readonly List _plans; + public OrganizationEditModel() { } public OrganizationEditModel(Provider provider) @@ -40,6 +43,7 @@ public class OrganizationEditModel : OrganizationViewModel BillingHistoryInfo billingHistoryInfo, IEnumerable connections, GlobalSettings globalSettings, + List plans, int secrets, int projects, int serviceAccounts, @@ -96,6 +100,8 @@ public class OrganizationEditModel : OrganizationViewModel MaxAutoscaleSmSeats = org.MaxAutoscaleSmSeats; SmServiceAccounts = org.SmServiceAccounts; MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; + + _plans = plans; } public BillingInfo BillingInfo { get; set; } @@ -183,7 +189,7 @@ public class OrganizationEditModel : OrganizationViewModel * Add mappings for individual properties as you need them */ public object GetPlansHelper() => - StaticStore.Plans + _plans .Select(p => { var plan = new diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 265aefc4ca..5a73e57204 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -55,6 +56,7 @@ public class OrganizationUsersController : Controller private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; private readonly IFeatureService _featureService; + private readonly IPricingClient _pricingClient; public OrganizationUsersController( IOrganizationRepository organizationRepository, @@ -77,7 +79,8 @@ public class OrganizationUsersController : Controller IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand, IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, - IFeatureService featureService) + IFeatureService featureService, + IPricingClient pricingClient) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -100,6 +103,7 @@ public class OrganizationUsersController : Controller _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; _featureService = featureService; + _pricingClient = pricingClient; } [HttpGet("{id}")] @@ -648,7 +652,9 @@ public class OrganizationUsersController : Controller if (additionalSmSeatsRequired > 0) { var organization = await _organizationRepository.GetByIdAsync(orgId); - var update = new SecretsManagerSubscriptionUpdate(organization, true) + // TODO: https://bitwarden.atlassian.net/browse/PM-17000 + var plan = await _pricingClient.GetPlanOrThrow(organization!.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, true) .AdjustSeats(additionalSmSeatsRequired); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update); } diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 85e8e990a6..34da3de10c 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -22,6 +22,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; @@ -60,6 +61,7 @@ public class OrganizationsController : Controller private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand; + private readonly IPricingClient _pricingClient; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -81,7 +83,8 @@ public class OrganizationsController : Controller IDataProtectorTokenFactory orgDeleteTokenDataFactory, IRemoveOrganizationUserCommand removeOrganizationUserCommand, ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand, - IOrganizationDeleteCommand organizationDeleteCommand) + IOrganizationDeleteCommand organizationDeleteCommand, + IPricingClient pricingClient) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -103,6 +106,7 @@ public class OrganizationsController : Controller _removeOrganizationUserCommand = removeOrganizationUserCommand; _cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand; _organizationDeleteCommand = organizationDeleteCommand; + _pricingClient = pricingClient; } [HttpGet("{id}")] @@ -120,7 +124,8 @@ public class OrganizationsController : Controller throw new NotFoundException(); } - return new OrganizationResponseModel(organization); + var plan = await _pricingClient.GetPlan(organization.PlanType); + return new OrganizationResponseModel(organization, plan); } [HttpGet("")] @@ -181,7 +186,8 @@ public class OrganizationsController : Controller var organizationSignup = model.ToOrganizationSignup(user); var result = await _cloudOrganizationSignUpCommand.SignUpOrganizationAsync(organizationSignup); - return new OrganizationResponseModel(result.Organization); + var plan = await _pricingClient.GetPlanOrThrow(result.Organization.PlanType); + return new OrganizationResponseModel(result.Organization, plan); } [HttpPost("create-without-payment")] @@ -196,7 +202,8 @@ public class OrganizationsController : Controller var organizationSignup = model.ToOrganizationSignup(user); var result = await _cloudOrganizationSignUpCommand.SignUpOrganizationAsync(organizationSignup); - return new OrganizationResponseModel(result.Organization); + var plan = await _pricingClient.GetPlanOrThrow(result.Organization.PlanType); + return new OrganizationResponseModel(result.Organization, plan); } [HttpPut("{id}")] @@ -224,7 +231,8 @@ public class OrganizationsController : Controller } await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling); - return new OrganizationResponseModel(organization); + var plan = await _pricingClient.GetPlan(organization.PlanType); + return new OrganizationResponseModel(organization, plan); } [HttpPost("{id}/storage")] @@ -358,8 +366,8 @@ public class OrganizationsController : Controller if (model.Type == OrganizationApiKeyType.BillingSync || model.Type == OrganizationApiKeyType.Scim) { // Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types - var plan = StaticStore.GetPlan(organization.PlanType); - if (plan.ProductTier is not ProductTierType.Enterprise and not ProductTierType.Teams) + var productTier = organization.PlanType.GetProductTier(); + if (productTier is not ProductTierType.Enterprise and not ProductTierType.Teams) { throw new NotFoundException(); } @@ -542,7 +550,8 @@ public class OrganizationsController : Controller } await _organizationService.UpdateAsync(model.ToOrganization(organization, _featureService), eventType: EventType.Organization_CollectionManagement_Updated); - return new OrganizationResponseModel(organization); + var plan = await _pricingClient.GetPlan(organization.PlanType); + return new OrganizationResponseModel(organization, plan); } [HttpGet("{id}/plan-type")] diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 272aaf6f9c..4dc4a4ec55 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; using Bit.Core.Utilities; using Constants = Bit.Core.Constants; @@ -11,8 +12,10 @@ namespace Bit.Api.AdminConsole.Models.Response.Organizations; public class OrganizationResponseModel : ResponseModel { - public OrganizationResponseModel(Organization organization, string obj = "organization") - : base(obj) + public OrganizationResponseModel( + Organization organization, + Plan plan, + string obj = "organization") : base(obj) { if (organization == null) { @@ -28,7 +31,8 @@ public class OrganizationResponseModel : ResponseModel BusinessCountry = organization.BusinessCountry; BusinessTaxNumber = organization.BusinessTaxNumber; BillingEmail = organization.BillingEmail; - Plan = new PlanResponseModel(StaticStore.GetPlan(organization.PlanType)); + // Self-Host instances only require plan information that can be derived from the Organization record. + Plan = plan != null ? new PlanResponseModel(plan) : new PlanResponseModel(organization); PlanType = organization.PlanType; Seats = organization.Seats; MaxAutoscaleSeats = organization.MaxAutoscaleSeats; @@ -110,7 +114,9 @@ public class OrganizationResponseModel : ResponseModel public class OrganizationSubscriptionResponseModel : OrganizationResponseModel { - public OrganizationSubscriptionResponseModel(Organization organization) : base(organization, "organizationSubscription") + public OrganizationSubscriptionResponseModel( + Organization organization, + Plan plan) : base(organization, plan, "organizationSubscription") { Expiration = organization.ExpirationDate; StorageName = organization.Storage.HasValue ? @@ -119,8 +125,11 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel Math.Round(organization.Storage.Value / 1073741824D, 2) : 0; // 1 GB } - public OrganizationSubscriptionResponseModel(Organization organization, SubscriptionInfo subscription, bool hideSensitiveData) - : this(organization) + public OrganizationSubscriptionResponseModel( + Organization organization, + SubscriptionInfo subscription, + Plan plan, + bool hideSensitiveData) : this(organization, plan) { Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; UpcomingInvoice = subscription.UpcomingInvoice != null ? new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; @@ -142,7 +151,7 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel } public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license) : - this(organization) + this(organization, (Plan)null) { if (license != null) { diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index d08298de6e..3a901f11c4 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Data; @@ -37,7 +38,7 @@ public class ProfileOrganizationResponseModel : ResponseModel UsePasswordManager = organization.UsePasswordManager; UsersGetPremium = organization.UsersGetPremium; UseCustomPermissions = organization.UseCustomPermissions; - UseActivateAutofillPolicy = StaticStore.GetPlan(organization.PlanType).ProductTier == ProductTierType.Enterprise; + UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise; SelfHost = organization.SelfHost; Seats = organization.Seats; MaxCollections = organization.MaxCollections; @@ -60,7 +61,7 @@ public class ProfileOrganizationResponseModel : ResponseModel FamilySponsorshipAvailable = FamilySponsorshipFriendlyName == null && StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) .UsersCanSponsor(organization); - ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier; + ProductTierType = organization.PlanType.GetProductTier(); FamilySponsorshipLastSyncDate = organization.FamilySponsorshipLastSyncDate; FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete; FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil; diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index 589744c7df..d31cb5a77a 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -1,8 +1,8 @@ using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Enums; using Bit.Core.Models.Data; -using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Response; @@ -26,7 +26,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo UseResetPassword = organization.UseResetPassword; UsersGetPremium = organization.UsersGetPremium; UseCustomPermissions = organization.UseCustomPermissions; - UseActivateAutofillPolicy = StaticStore.GetPlan(organization.PlanType).ProductTier == ProductTierType.Enterprise; + UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise; SelfHost = organization.SelfHost; Seats = organization.Seats; MaxCollections = organization.MaxCollections; @@ -44,7 +44,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo ProviderId = organization.ProviderId; ProviderName = organization.ProviderName; ProviderType = organization.ProviderType; - ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier; + ProductTierType = organization.PlanType.GetProductTier(); LimitCollectionCreation = organization.LimitCollectionCreation; LimitCollectionDeletion = organization.LimitCollectionDeletion; LimitItemDeletion = organization.LimitItemDeletion; diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 3ceeaf3c47..2ec503281e 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -4,6 +4,7 @@ using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Repositories; @@ -21,6 +22,7 @@ public class OrganizationBillingController( IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IPaymentService paymentService, + IPricingClient pricingClient, ISubscriberService subscriberService, IPaymentHistoryService paymentHistoryService, IUserService userService) : BaseBillingController @@ -279,7 +281,7 @@ public class OrganizationBillingController( } var organizationSignup = model.ToOrganizationSignup(user); var sale = OrganizationSale.From(organization, organizationSignup); - var plan = StaticStore.GetPlan(model.PlanType); + var plan = await pricingClient.GetPlanOrThrow(model.PlanType); sale.Organization.PlanType = plan.Type; sale.Organization.Plan = plan.Name; sale.SubscriptionSetup.SkipTrial = true; diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 7b25114a44..de14a8d798 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -45,7 +46,8 @@ public class OrganizationsController( IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, IReferenceEventService referenceEventService, ISubscriberService subscriberService, - IOrganizationInstallationRepository organizationInstallationRepository) + IOrganizationInstallationRepository organizationInstallationRepository, + IPricingClient pricingClient) : Controller { [HttpGet("{id:guid}/subscription")] @@ -62,26 +64,28 @@ public class OrganizationsController( throw new NotFoundException(); } - if (!globalSettings.SelfHosted && organization.Gateway != null) - { - var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization); - if (subscriptionInfo == null) - { - throw new NotFoundException(); - } - - var hideSensitiveData = !await currentContext.EditSubscription(id); - - return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData); - } - if (globalSettings.SelfHosted) { var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization); return new OrganizationSubscriptionResponseModel(organization, orgLicense); } - return new OrganizationSubscriptionResponseModel(organization); + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + if (string.IsNullOrEmpty(organization.GatewaySubscriptionId)) + { + return new OrganizationSubscriptionResponseModel(organization, plan); + } + + var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization); + if (subscriptionInfo == null) + { + throw new NotFoundException(); + } + + var hideSensitiveData = !await currentContext.EditSubscription(id); + + return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, plan, hideSensitiveData); } [HttpGet("{id:guid}/license")] @@ -165,7 +169,8 @@ public class OrganizationsController( organization = await AdjustOrganizationSeatsForSmTrialAsync(id, organization, model); - var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization); + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization, plan); await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate); diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index c5de63c69b..73c992040c 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -2,6 +2,7 @@ using Bit.Api.Billing.Models.Responses; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -20,6 +21,7 @@ namespace Bit.Api.Billing.Controllers; public class ProviderBillingController( ICurrentContext currentContext, ILogger logger, + IPricingClient pricingClient, IProviderBillingService providerBillingService, IProviderPlanRepository providerPlanRepository, IProviderRepository providerRepository, @@ -84,13 +86,25 @@ public class ProviderBillingController( var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + var configuredProviderPlans = await Task.WhenAll(providerPlans.Select(async providerPlan => + { + var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); + return new ConfiguredProviderPlan( + providerPlan.Id, + providerPlan.ProviderId, + plan, + providerPlan.SeatMinimum ?? 0, + providerPlan.PurchasedSeats ?? 0, + providerPlan.AllocatedSeats ?? 0); + })); + var taxInformation = GetTaxInformation(subscription.Customer); var subscriptionSuspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription); var response = ProviderSubscriptionResponse.From( subscription, - providerPlans, + configuredProviderPlans, taxInformation, subscriptionSuspension, provider); diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs index 2b0592f0e3..34c3817e51 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs @@ -1,9 +1,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; -using Bit.Core.Utilities; using Stripe; namespace Bit.Api.Billing.Models.Responses; @@ -25,26 +23,24 @@ public record ProviderSubscriptionResponse( public static ProviderSubscriptionResponse From( Subscription subscription, - ICollection providerPlans, + ICollection providerPlans, TaxInformation taxInformation, SubscriptionSuspension subscriptionSuspension, Provider provider) { var providerPlanResponses = providerPlans - .Where(providerPlan => providerPlan.IsConfigured()) - .Select(ConfiguredProviderPlan.From) - .Select(configuredProviderPlan => + .Select(providerPlan => { - var plan = StaticStore.GetPlan(configuredProviderPlan.PlanType); - var cost = (configuredProviderPlan.SeatMinimum + configuredProviderPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice; + var plan = providerPlan.Plan; + var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice; var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence; return new ProviderPlanResponse( plan.Name, plan.Type, plan.ProductTier, - configuredProviderPlan.SeatMinimum, - configuredProviderPlan.PurchasedSeats, - configuredProviderPlan.AssignedSeats, + providerPlan.SeatMinimum, + providerPlan.PurchasedSeats, + providerPlan.AssignedSeats, cost, cadence); }); diff --git a/src/Api/Billing/Public/Controllers/OrganizationController.cs b/src/Api/Billing/Public/Controllers/OrganizationController.cs index 7fcd94acd3..b0a0537ed8 100644 --- a/src/Api/Billing/Public/Controllers/OrganizationController.cs +++ b/src/Api/Billing/Public/Controllers/OrganizationController.cs @@ -1,6 +1,7 @@ using System.Net; using Bit.Api.Billing.Public.Models; using Bit.Api.Models.Public.Response; +using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; @@ -21,19 +22,22 @@ public class OrganizationController : Controller private readonly IOrganizationRepository _organizationRepository; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly ILogger _logger; + private readonly IPricingClient _pricingClient; public OrganizationController( IOrganizationService organizationService, ICurrentContext currentContext, IOrganizationRepository organizationRepository, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, - ILogger logger) + ILogger logger, + IPricingClient pricingClient) { _organizationService = organizationService; _currentContext = currentContext; _organizationRepository = organizationRepository; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _logger = logger; + _pricingClient = pricingClient; } /// @@ -140,7 +144,8 @@ public class OrganizationController : Controller return "Organization has no access to Secrets Manager."; } - var secretsManagerUpdate = model.SecretsManager.ToSecretsManagerSubscriptionUpdate(organization); + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + var secretsManagerUpdate = model.SecretsManager.ToSecretsManagerSubscriptionUpdate(organization, plan); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(secretsManagerUpdate); return string.Empty; diff --git a/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs b/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs index 781ad3ca53..5c75db5924 100644 --- a/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs +++ b/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; namespace Bit.Api.Billing.Public.Models; @@ -93,17 +94,17 @@ public class SecretsManagerSubscriptionUpdateModel set { _maxAutoScaleServiceAccounts = value < 0 ? null : value; } } - public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization) + public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization, Plan plan) { - var update = UpdateUpdateMaxAutoScale(organization); + var update = UpdateUpdateMaxAutoScale(organization, plan); UpdateSeats(organization, update); UpdateServiceAccounts(organization, update); return update; } - private SecretsManagerSubscriptionUpdate UpdateUpdateMaxAutoScale(Organization organization) + private SecretsManagerSubscriptionUpdate UpdateUpdateMaxAutoScale(Organization organization, Plan plan) { - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { MaxAutoscaleSmSeats = MaxAutoScaleSeats ?? organization.MaxAutoscaleSmSeats, MaxAutoscaleSmServiceAccounts = MaxAutoScaleServiceAccounts ?? organization.MaxAutoscaleSmServiceAccounts diff --git a/src/Api/Controllers/PlansController.cs b/src/Api/Controllers/PlansController.cs index c2ee494322..11b070fb66 100644 --- a/src/Api/Controllers/PlansController.cs +++ b/src/Api/Controllers/PlansController.cs @@ -1,5 +1,5 @@ using Bit.Api.Models.Response; -using Bit.Core.Utilities; +using Bit.Core.Billing.Pricing; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,13 +7,15 @@ namespace Bit.Api.Controllers; [Route("plans")] [Authorize("Web")] -public class PlansController : Controller +public class PlansController( + IPricingClient pricingClient) : Controller { [HttpGet("")] [AllowAnonymous] - public ListResponseModel Get() + public async Task> Get() { - var responses = StaticStore.Plans.Select(plan => new PlanResponseModel(plan)); + var plans = await pricingClient.ListPlans(); + var responses = plans.Select(plan => new PlanResponseModel(plan)); return new ListResponseModel(responses); } } diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs index 783c4b71f4..ed501c41da 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs @@ -64,7 +64,8 @@ public class SelfHostedOrganizationLicensesController : Controller var result = await _organizationService.SignUpAsync(license, user, model.Key, model.CollectionName, model.Keys?.PublicKey, model.Keys?.EncryptedPrivateKey); - return new OrganizationResponseModel(result.Item1); + + return new OrganizationResponseModel(result.Item1, null); } [HttpPost("{id}")] diff --git a/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs b/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs index 18bc66a0b6..6ddc1af486 100644 --- a/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; namespace Bit.Api.Models.Request.Organizations; @@ -12,9 +13,9 @@ public class SecretsManagerSubscriptionUpdateRequestModel public int ServiceAccountAdjustment { get; set; } public int? MaxAutoscaleServiceAccounts { get; set; } - public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization) + public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization, Plan plan) { - return new SecretsManagerSubscriptionUpdate(organization, false) + return new SecretsManagerSubscriptionUpdate(organization, plan, false) { MaxAutoscaleSmSeats = MaxAutoscaleSeats, MaxAutoscaleSmServiceAccounts = MaxAutoscaleServiceAccounts diff --git a/src/Api/Models/Response/PlanResponseModel.cs b/src/Api/Models/Response/PlanResponseModel.cs index b6ca9b62d2..74bcb59661 100644 --- a/src/Api/Models/Response/PlanResponseModel.cs +++ b/src/Api/Models/Response/PlanResponseModel.cs @@ -1,4 +1,6 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Models.Api; using Bit.Core.Models.StaticStore; @@ -44,6 +46,13 @@ public class PlanResponseModel : ResponseModel PasswordManager = new PasswordManagerPlanFeaturesResponseModel(plan.PasswordManager); } + public PlanResponseModel(Organization organization, string obj = "plan") : base(obj) + { + Type = organization.PlanType; + ProductTier = organization.PlanType.GetProductTier(); + Name = organization.Plan; + } + public PlanType Type { get; set; } public ProductTierType ProductTier { get; set; } public string Name { get; set; } diff --git a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs index 8de53bc1e4..96c6c60528 100644 --- a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs +++ b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs @@ -1,6 +1,7 @@ using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -37,6 +38,7 @@ public class ServiceAccountsController : Controller private readonly IUpdateServiceAccountCommand _updateServiceAccountCommand; private readonly IDeleteServiceAccountsCommand _deleteServiceAccountsCommand; private readonly IRevokeAccessTokensCommand _revokeAccessTokensCommand; + private readonly IPricingClient _pricingClient; public ServiceAccountsController( ICurrentContext currentContext, @@ -52,7 +54,8 @@ public class ServiceAccountsController : Controller ICreateServiceAccountCommand createServiceAccountCommand, IUpdateServiceAccountCommand updateServiceAccountCommand, IDeleteServiceAccountsCommand deleteServiceAccountsCommand, - IRevokeAccessTokensCommand revokeAccessTokensCommand) + IRevokeAccessTokensCommand revokeAccessTokensCommand, + IPricingClient pricingClient) { _currentContext = currentContext; _userService = userService; @@ -66,6 +69,7 @@ public class ServiceAccountsController : Controller _updateServiceAccountCommand = updateServiceAccountCommand; _deleteServiceAccountsCommand = deleteServiceAccountsCommand; _revokeAccessTokensCommand = revokeAccessTokensCommand; + _pricingClient = pricingClient; _createAccessTokenCommand = createAccessTokenCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; } @@ -124,7 +128,9 @@ public class ServiceAccountsController : Controller if (newServiceAccountSlotsRequired > 0) { var org = await _organizationRepository.GetByIdAsync(organizationId); - var update = new SecretsManagerSubscriptionUpdate(org, true) + // TODO: https://bitwarden.atlassian.net/browse/PM-17002 + var plan = await _pricingClient.GetPlanOrThrow(org!.PlanType); + var update = new SecretsManagerSubscriptionUpdate(org, plan, true) .AdjustServiceAccounts(newServiceAccountSlotsRequired); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update); } diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 1577e77c9e..40d8c8349d 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Platform.Push; using Bit.Core.Repositories; @@ -9,7 +10,6 @@ using Bit.Core.Services; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; -using Bit.Core.Utilities; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -28,6 +28,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationEnableCommand _organizationEnableCommand; + private readonly IPricingClient _pricingClient; public PaymentSucceededHandler( ILogger logger, @@ -41,7 +42,8 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler IStripeEventUtilityService stripeEventUtilityService, IUserService userService, IPushNotificationService pushNotificationService, - IOrganizationEnableCommand organizationEnableCommand) + IOrganizationEnableCommand organizationEnableCommand, + IPricingClient pricingClient) { _logger = logger; _stripeEventService = stripeEventService; @@ -55,6 +57,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler _userService = userService; _pushNotificationService = pushNotificationService; _organizationEnableCommand = organizationEnableCommand; + _pricingClient = pricingClient; } /// @@ -96,9 +99,9 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler return; } - var teamsMonthly = StaticStore.GetPlan(PlanType.TeamsMonthly); + var teamsMonthly = await _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly); - var enterpriseMonthly = StaticStore.GetPlan(PlanType.EnterpriseMonthly); + var enterpriseMonthly = await _pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly); var teamsMonthlyLineItem = subscription.Items.Data.FirstOrDefault(item => @@ -137,14 +140,21 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler } else if (organizationId.HasValue) { - if (!subscription.Items.Any(i => - StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id))) + var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + + if (organization == null) + { + return; + } + + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + + if (subscription.Items.All(item => plan.PasswordManager.StripePlanId != item.Plan.Id)) { return; } await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); - var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); await _referenceEventService.RaiseEventAsync( diff --git a/src/Billing/Services/Implementations/ProviderEventService.cs b/src/Billing/Services/Implementations/ProviderEventService.cs index 548ed9f547..4e35a6c894 100644 --- a/src/Billing/Services/Implementations/ProviderEventService.cs +++ b/src/Billing/Services/Implementations/ProviderEventService.cs @@ -1,15 +1,17 @@ using Bit.Billing.Constants; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Enums; -using Bit.Core.Utilities; +using Bit.Core.Repositories; using Stripe; namespace Bit.Billing.Services.Implementations; public class ProviderEventService( - ILogger logger, + IOrganizationRepository organizationRepository, + IPricingClient pricingClient, IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, @@ -54,7 +56,14 @@ public class ProviderEventService( continue; } - var plan = StaticStore.Plans.Single(x => x.Name == client.Plan && providerPlans.Any(y => y.PlanType == x.Type)); + var organization = await organizationRepository.GetByIdAsync(client.OrganizationId); + + if (organization == null) + { + return; + } + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100; @@ -76,7 +85,7 @@ public class ProviderEventService( foreach (var providerPlan in providerPlans.Where(x => x.PurchasedSeats is null or 0)) { - var plan = StaticStore.GetPlan(providerPlan.PlanType); + var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); var clientSeats = invoiceItems .Where(item => item.PlanName == plan.Name) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 35a16ae74f..d2ca7fa9bf 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -2,11 +2,11 @@ using Bit.Billing.Jobs; using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; using Quartz; using Stripe; using Event = Stripe.Event; @@ -27,6 +27,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IFeatureService _featureService; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly IPricingClient _pricingClient; public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, @@ -40,7 +41,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler ISchedulerFactory schedulerFactory, IFeatureService featureService, IOrganizationEnableCommand organizationEnableCommand, - IOrganizationDisableCommand organizationDisableCommand) + IOrganizationDisableCommand organizationDisableCommand, + IPricingClient pricingClient) { _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; @@ -54,6 +56,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler _featureService = featureService; _organizationEnableCommand = organizationEnableCommand; _organizationDisableCommand = organizationDisableCommand; + _pricingClient = pricingClient; } /// @@ -156,7 +159,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler /// /// /// - private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(Event parsedEvent, + private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync( + Event parsedEvent, Subscription subscription) { if (parsedEvent.Data.PreviousAttributes?.items is null) @@ -164,6 +168,22 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler return; } + var organization = subscription.Metadata.TryGetValue("organizationId", out var organizationId) + ? await _organizationRepository.GetByIdAsync(Guid.Parse(organizationId)) + : null; + + if (organization == null) + { + return; + } + + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + + if (!plan.SupportsSecretsManager) + { + return; + } + var previousSubscription = parsedEvent.Data .PreviousAttributes .ToObject() as Subscription; @@ -171,17 +191,14 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler // This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager. // If there are changes to any subscription item, Stripe sends every item in the subscription, both // changed and unchanged. - var previousSubscriptionHasSecretsManager = previousSubscription?.Items is not null && - previousSubscription.Items.Any(previousItem => - StaticStore.Plans.Any(p => - p.SecretsManager is not null && - p.SecretsManager.StripeSeatPlanId == - previousItem.Plan.Id)); + var previousSubscriptionHasSecretsManager = + previousSubscription?.Items is not null && + previousSubscription.Items.Any( + previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); - var currentSubscriptionHasSecretsManager = subscription.Items.Any(i => - StaticStore.Plans.Any(p => - p.SecretsManager is not null && - p.SecretsManager.StripeSeatPlanId == i.Plan.Id)); + var currentSubscriptionHasSecretsManager = + subscription.Items.Any( + currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager) { diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 5315195c59..d37bf41428 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,12 +1,11 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; using Stripe; using Event = Stripe.Event; @@ -16,6 +15,7 @@ public class UpcomingInvoiceHandler( ILogger logger, IMailService mailService, IOrganizationRepository organizationRepository, + IPricingClient pricingClient, IProviderRepository providerRepository, IStripeFacade stripeFacade, IStripeEventService stripeEventService, @@ -52,7 +52,9 @@ public class UpcomingInvoiceHandler( await TryEnableAutomaticTaxAsync(subscription); - if (!HasAnnualPlan(organization)) + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + if (!plan.IsAnnual) { return; } @@ -136,7 +138,7 @@ public class UpcomingInvoiceHandler( { if (subscription.AutomaticTax.Enabled || !subscription.Customer.HasBillingLocation() || - IsNonTaxableNonUSBusinessUseSubscription(subscription)) + await IsNonTaxableNonUSBusinessUseSubscription(subscription)) { return; } @@ -150,14 +152,12 @@ public class UpcomingInvoiceHandler( return; - bool IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription) + async Task IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription) { - var familyPriceIds = new List - { - // TODO: Replace with the PricingClient - StaticStore.GetPlan(PlanType.FamiliesAnnually2019).PasswordManager.StripePlanId, - StaticStore.GetPlan(PlanType.FamiliesAnnually).PasswordManager.StripePlanId - }; + var familyPriceIds = (await Task.WhenAll( + pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), + pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) + .Select(plan => plan.PasswordManager.StripePlanId); return localSubscription.Customer.Address.Country != "US" && localSubscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && @@ -165,6 +165,4 @@ public class UpcomingInvoiceHandler( !localSubscription.Customer.TaxIds.Any(); } } - - private static bool HasAnnualPlan(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs index 3dd55f9893..657cc6c54d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -24,6 +25,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand private readonly ICollectionRepository _collectionRepository; private readonly IGroupRepository _groupRepository; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; + private readonly IPricingClient _pricingClient; public UpdateOrganizationUserCommand( IEventService eventService, @@ -34,7 +36,8 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, ICollectionRepository collectionRepository, IGroupRepository groupRepository, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IPricingClient pricingClient) { _eventService = eventService; _organizationService = organizationService; @@ -45,6 +48,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand _collectionRepository = collectionRepository; _groupRepository = groupRepository; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; + _pricingClient = pricingClient; } /// @@ -128,8 +132,10 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organizationUser.OrganizationId, 1); if (additionalSmSeatsRequired > 0) { - var update = new SecretsManagerSubscriptionUpdate(organization, true) - .AdjustSeats(additionalSmSeatsRequired); + // TODO: https://bitwarden.atlassian.net/browse/PM-17012 + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, true) + .AdjustSeats(additionalSmSeatsRequired); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 94ff3c0059..57cfd1e60f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -45,11 +46,12 @@ public class CloudOrganizationSignUpCommand( IPushRegistrationService pushRegistrationService, IPushNotificationService pushNotificationService, ICollectionRepository collectionRepository, - IDeviceRepository deviceRepository) : ICloudOrganizationSignUpCommand + IDeviceRepository deviceRepository, + IPricingClient pricingClient) : ICloudOrganizationSignUpCommand { public async Task SignUpOrganizationAsync(OrganizationSignup signup) { - var plan = StaticStore.GetPlan(signup.Plan); + var plan = await pricingClient.GetPlanOrThrow(signup.Plan); ValidatePasswordManagerPlan(plan, signup); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 14cf89a246..1b44eea496 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -16,6 +16,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -74,6 +75,7 @@ public class OrganizationService : IOrganizationService private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IOrganizationBillingService _organizationBillingService; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; + private readonly IPricingClient _pricingClient; public OrganizationService( IOrganizationRepository organizationRepository, @@ -108,7 +110,8 @@ public class OrganizationService : IOrganizationService IFeatureService featureService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IOrganizationBillingService organizationBillingService, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IPricingClient pricingClient) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -143,6 +146,7 @@ public class OrganizationService : IOrganizationService _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _organizationBillingService = organizationBillingService; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; + _pricingClient = pricingClient; } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -210,11 +214,7 @@ public class OrganizationService : IOrganizationService throw new NotFoundException(); } - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); - if (plan == null) - { - throw new BadRequestException("Existing plan not found."); - } + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); if (!plan.PasswordManager.HasAdditionalStorageOption) { @@ -268,7 +268,7 @@ public class OrganizationService : IOrganizationService throw new BadRequestException($"Cannot set max seat autoscaling below current seat count."); } - var plan = StaticStore.GetPlan(organization.PlanType); + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); if (plan == null) { throw new BadRequestException("Existing plan not found."); @@ -320,11 +320,7 @@ public class OrganizationService : IOrganizationService throw new BadRequestException("No subscription found."); } - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); - if (plan == null) - { - throw new BadRequestException("Existing plan not found."); - } + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); if (!plan.PasswordManager.HasAdditionalSeatsOption) { @@ -442,7 +438,7 @@ public class OrganizationService : IOrganizationService public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup) { - var plan = StaticStore.GetPlan(signup.Plan); + var plan = await _pricingClient.GetPlanOrThrow(signup.Plan); ValidatePlan(plan, signup.AdditionalSeats, "Password Manager"); @@ -530,17 +526,6 @@ public class OrganizationService : IOrganizationService throw new BadRequestException(exception); } - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == license.PlanType); - if (plan is null) - { - throw new BadRequestException($"Server must be updated to support {license.Plan}."); - } - - if (license.PlanType != PlanType.Custom && plan.Disabled) - { - throw new BadRequestException($"Plan {plan.Name} is disabled."); - } - var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync(); if (enabledOrgs.Any(o => string.Equals(o.LicenseKey, license.LicenseKey))) { @@ -882,7 +867,8 @@ public class OrganizationService : IOrganizationService var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, inviteWithSmAccessCount); if (additionalSmSeatsRequired > 0) { - smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, true) + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, plan, true) .AdjustSeats(additionalSmSeatsRequired); } @@ -1008,7 +994,8 @@ public class OrganizationService : IOrganizationService if (initialSmSeatCount.HasValue && currentOrganization.SmSeats.HasValue && currentOrganization.SmSeats.Value != initialSmSeatCount.Value) { - var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(currentOrganization, false) + var plan = await _pricingClient.GetPlanOrThrow(currentOrganization.PlanType); + var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(currentOrganization, plan, false) { SmSeats = initialSmSeatCount.Value }; @@ -2237,13 +2224,6 @@ public class OrganizationService : IOrganizationService public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted) { - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); - - if (plan!.Disabled) - { - throw new BadRequestException("Plan not found."); - } - organization.Id = CoreHelpers.GenerateComb(); organization.Enabled = false; organization.Status = OrganizationStatusType.Pending; diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index e3c2b7245e..0a5faae947 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -34,6 +34,7 @@ public static class StripeConstants public static class InvoiceStatus { public const string Draft = "draft"; + public const string Open = "open"; } public static class MetadataKeys diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 39b92e95a2..f6e65861cd 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -10,6 +10,17 @@ namespace Bit.Core.Billing.Extensions; public static class BillingExtensions { + public static ProductTierType GetProductTier(this PlanType planType) + => planType switch + { + PlanType.Custom or PlanType.Free => ProductTierType.Free, + PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families, + PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter, + _ when planType.ToString().Contains("Teams") => ProductTierType.Teams, + _ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise, + _ => throw new BillingException($"PlanType {planType} could not be matched to a ProductTierType") + }; + public static bool IsBillable(this Provider provider) => provider is { diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 9a7a4107ae..26815d7df0 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches.Implementations; using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; @@ -17,7 +18,7 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); - // services.AddSingleton(); services.AddLicenseServices(); + services.AddPricingClient(); } } diff --git a/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs b/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs index a24193f133..4d93c0119a 100644 --- a/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs +++ b/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs @@ -3,11 +3,11 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; using Plan = Bit.Core.Models.StaticStore.Plan; @@ -19,6 +19,7 @@ public class OrganizationMigrator( ILogger logger, IMigrationTrackerCache migrationTrackerCache, IOrganizationRepository organizationRepository, + IPricingClient pricingClient, IStripeAdapter stripeAdapter) : IOrganizationMigrator { private const string _cancellationComment = "Cancelled as part of provider migration to Consolidated Billing"; @@ -137,7 +138,7 @@ public class OrganizationMigrator( logger.LogInformation("CB: Bringing organization ({OrganizationID}) under provider management", organization.Id); - var plan = StaticStore.GetPlan(organization.Plan.Contains("Teams") ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly); + var plan = await pricingClient.GetPlanOrThrow(organization.Plan.Contains("Teams") ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly); ResetOrganizationPlan(organization, plan); organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; @@ -206,7 +207,7 @@ public class OrganizationMigrator( ? StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice; - var plan = StaticStore.GetPlan(organization.PlanType); + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); var items = new List { @@ -279,7 +280,7 @@ public class OrganizationMigrator( throw new Exception(); } - var plan = StaticStore.GetPlan(migrationRecord.PlanType); + var plan = await pricingClient.GetPlanOrThrow(migrationRecord.PlanType); ResetOrganizationPlan(organization, plan); organization.MaxStorageGb = migrationRecord.MaxStorageGb; diff --git a/src/Core/Billing/Models/ConfiguredProviderPlan.cs b/src/Core/Billing/Models/ConfiguredProviderPlan.cs index dadb176533..72c1ec5b07 100644 --- a/src/Core/Billing/Models/ConfiguredProviderPlan.cs +++ b/src/Core/Billing/Models/ConfiguredProviderPlan.cs @@ -1,24 +1,11 @@ -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Enums; +using Bit.Core.Models.StaticStore; namespace Bit.Core.Billing.Models; public record ConfiguredProviderPlan( Guid Id, Guid ProviderId, - PlanType PlanType, + Plan Plan, int SeatMinimum, int PurchasedSeats, - int AssignedSeats) -{ - public static ConfiguredProviderPlan From(ProviderPlan providerPlan) => - providerPlan.IsConfigured() - ? new ConfiguredProviderPlan( - providerPlan.Id, - providerPlan.ProviderId, - providerPlan.PlanType, - providerPlan.SeatMinimum.GetValueOrDefault(0), - providerPlan.PurchasedSeats.GetValueOrDefault(0), - providerPlan.AllocatedSeats.GetValueOrDefault(0)) - : null; -} + int AssignedSeats); diff --git a/src/Core/Billing/Models/OrganizationMetadata.cs b/src/Core/Billing/Models/OrganizationMetadata.cs index 4bb9a85825..41666949bf 100644 --- a/src/Core/Billing/Models/OrganizationMetadata.cs +++ b/src/Core/Billing/Models/OrganizationMetadata.cs @@ -10,4 +10,17 @@ public record OrganizationMetadata( bool IsSubscriptionCanceled, DateTime? InvoiceDueDate, DateTime? InvoiceCreatedDate, - DateTime? SubPeriodEndDate); + DateTime? SubPeriodEndDate) +{ + public static OrganizationMetadata Default => new OrganizationMetadata( + false, + false, + false, + false, + false, + false, + false, + null, + null, + null); +} diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs index 43852bb320..f0ad7894e6 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs @@ -76,8 +76,6 @@ public class OrganizationSale private static SubscriptionSetup GetSubscriptionSetup(OrganizationUpgrade upgrade) { - var plan = Core.Utilities.StaticStore.GetPlan(upgrade.Plan); - var passwordManagerOptions = new SubscriptionSetup.PasswordManager { Seats = upgrade.AdditionalSeats, @@ -95,7 +93,7 @@ public class OrganizationSale return new SubscriptionSetup { - Plan = plan, + PlanType = upgrade.Plan, PasswordManagerOptions = passwordManagerOptions, SecretsManagerOptions = secretsManagerOptions }; diff --git a/src/Core/Billing/Models/Sales/SubscriptionSetup.cs b/src/Core/Billing/Models/Sales/SubscriptionSetup.cs index 39a80a776a..871a2920b1 100644 --- a/src/Core/Billing/Models/Sales/SubscriptionSetup.cs +++ b/src/Core/Billing/Models/Sales/SubscriptionSetup.cs @@ -1,4 +1,4 @@ -using Bit.Core.Models.StaticStore; +using Bit.Core.Billing.Enums; namespace Bit.Core.Billing.Models.Sales; @@ -6,7 +6,7 @@ namespace Bit.Core.Billing.Models.Sales; public class SubscriptionSetup { - public required Plan Plan { get; set; } + public required PlanType PlanType { get; set; } public required PasswordManager PasswordManagerOptions { get; set; } public SecretsManager? SecretsManagerOptions { get; set; } public bool SkipTrial = false; diff --git a/src/Core/Billing/Pricing/IPricingClient.cs b/src/Core/Billing/Pricing/IPricingClient.cs index 68577f1db3..bc3f142dda 100644 --- a/src/Core/Billing/Pricing/IPricingClient.cs +++ b/src/Core/Billing/Pricing/IPricingClient.cs @@ -1,5 +1,7 @@ using Bit.Core.Billing.Enums; +using Bit.Core.Exceptions; using Bit.Core.Models.StaticStore; +using Bit.Core.Utilities; #nullable enable @@ -7,6 +9,30 @@ namespace Bit.Core.Billing.Pricing; public interface IPricingClient { + /// + /// Retrieve a Bitwarden plan by its . If the feature flag 'use-pricing-service' is enabled, + /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing . + /// + /// The type of plan to retrieve. + /// A Bitwarden record or null in the case the plan could not be found or the method was executed from a self-hosted instance. + /// Thrown when the request to the Pricing Service fails unexpectedly. Task GetPlan(PlanType planType); + + /// + /// Retrieve a Bitwarden plan by its . If the feature flag 'use-pricing-service' is enabled, + /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing . + /// + /// The type of plan to retrieve. + /// A Bitwarden record. + /// Thrown when the for the provided could not be found or the method was executed from a self-hosted instance. + /// Thrown when the request to the Pricing Service fails unexpectedly. + Task GetPlanOrThrow(PlanType planType); + + /// + /// Retrieve all the Bitwarden plans. If the feature flag 'use-pricing-service' is enabled, + /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing . + /// + /// A list of Bitwarden records or an empty list in the case the method is executed from a self-hosted instance. + /// Thrown when the request to the Pricing Service fails unexpectedly. Task> ListPlans(); } diff --git a/src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs b/src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs new file mode 100644 index 0000000000..37a8a4234d --- /dev/null +++ b/src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using Bit.Core.Billing.Pricing.Models; + +namespace Bit.Core.Billing.Pricing.JSON; + +#nullable enable + +public class FreeOrScalableDTOJsonConverter : TypeReadingJsonConverter +{ + public override FreeOrScalableDTO? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var type = ReadType(reader); + + return type switch + { + "free" => JsonSerializer.Deserialize(ref reader, options) switch + { + null => null, + var free => new FreeOrScalableDTO(free) + }, + "scalable" => JsonSerializer.Deserialize(ref reader, options) switch + { + null => null, + var scalable => new FreeOrScalableDTO(scalable) + }, + _ => null + }; + } + + public override void Write(Utf8JsonWriter writer, FreeOrScalableDTO value, JsonSerializerOptions options) + => value.Switch( + free => JsonSerializer.Serialize(writer, free, options), + scalable => JsonSerializer.Serialize(writer, scalable, options) + ); +} diff --git a/src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs b/src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs new file mode 100644 index 0000000000..f7ae9dc472 --- /dev/null +++ b/src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using Bit.Core.Billing.Pricing.Models; + +namespace Bit.Core.Billing.Pricing.JSON; + +#nullable enable +internal class PurchasableDTOJsonConverter : TypeReadingJsonConverter +{ + public override PurchasableDTO? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var type = ReadType(reader); + + return type switch + { + "free" => JsonSerializer.Deserialize(ref reader, options) switch + { + null => null, + var free => new PurchasableDTO(free) + }, + "packaged" => JsonSerializer.Deserialize(ref reader, options) switch + { + null => null, + var packaged => new PurchasableDTO(packaged) + }, + "scalable" => JsonSerializer.Deserialize(ref reader, options) switch + { + null => null, + var scalable => new PurchasableDTO(scalable) + }, + _ => null + }; + } + + public override void Write(Utf8JsonWriter writer, PurchasableDTO value, JsonSerializerOptions options) + => value.Switch( + free => JsonSerializer.Serialize(writer, free, options), + packaged => JsonSerializer.Serialize(writer, packaged, options), + scalable => JsonSerializer.Serialize(writer, scalable, options) + ); +} diff --git a/src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs b/src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs new file mode 100644 index 0000000000..ef8d33304e --- /dev/null +++ b/src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Pricing.Models; + +namespace Bit.Core.Billing.Pricing.JSON; + +#nullable enable + +public abstract class TypeReadingJsonConverter : JsonConverter +{ + protected virtual string TypePropertyName => nameof(ScalableDTO.Type).ToLower(); + + protected string? ReadType(Utf8JsonReader reader) + { + while (reader.Read()) + { + if (reader.TokenType != JsonTokenType.PropertyName || reader.GetString()?.ToLower() != TypePropertyName) + { + continue; + } + + reader.Read(); + return reader.GetString(); + } + + return null; + } +} diff --git a/src/Core/Billing/Pricing/Models/FeatureDTO.cs b/src/Core/Billing/Pricing/Models/FeatureDTO.cs new file mode 100644 index 0000000000..a96ac019e3 --- /dev/null +++ b/src/Core/Billing/Pricing/Models/FeatureDTO.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Billing.Pricing.Models; + +#nullable enable + +public class FeatureDTO +{ + public string Name { get; set; } = null!; + public string LookupKey { get; set; } = null!; +} diff --git a/src/Core/Billing/Pricing/Models/PlanDTO.cs b/src/Core/Billing/Pricing/Models/PlanDTO.cs new file mode 100644 index 0000000000..4ae82b3efe --- /dev/null +++ b/src/Core/Billing/Pricing/Models/PlanDTO.cs @@ -0,0 +1,27 @@ +namespace Bit.Core.Billing.Pricing.Models; + +#nullable enable + +public class PlanDTO +{ + public string LookupKey { get; set; } = null!; + public string Name { get; set; } = null!; + public string Tier { get; set; } = null!; + public string? Cadence { get; set; } + public int? LegacyYear { get; set; } + public bool Available { get; set; } + public FeatureDTO[] Features { get; set; } = null!; + public PurchasableDTO Seats { get; set; } = null!; + public ScalableDTO? ManagedSeats { get; set; } + public ScalableDTO? Storage { get; set; } + public SecretsManagerPurchasablesDTO? SecretsManager { get; set; } + public int? TrialPeriodDays { get; set; } + public string[] CanUpgradeTo { get; set; } = null!; + public Dictionary AdditionalData { get; set; } = null!; +} + +public class SecretsManagerPurchasablesDTO +{ + public FreeOrScalableDTO Seats { get; set; } = null!; + public FreeOrScalableDTO ServiceAccounts { get; set; } = null!; +} diff --git a/src/Core/Billing/Pricing/Models/PurchasableDTO.cs b/src/Core/Billing/Pricing/Models/PurchasableDTO.cs new file mode 100644 index 0000000000..8ba1c7b731 --- /dev/null +++ b/src/Core/Billing/Pricing/Models/PurchasableDTO.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Serialization; +using Bit.Core.Billing.Pricing.JSON; +using OneOf; + +namespace Bit.Core.Billing.Pricing.Models; + +#nullable enable + +[JsonConverter(typeof(PurchasableDTOJsonConverter))] +public class PurchasableDTO(OneOf input) : OneOfBase(input) +{ + public static implicit operator PurchasableDTO(FreeDTO free) => new(free); + public static implicit operator PurchasableDTO(PackagedDTO packaged) => new(packaged); + public static implicit operator PurchasableDTO(ScalableDTO scalable) => new(scalable); + + public T? FromFree(Func select, Func? fallback = null) => + IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; + + public T? FromPackaged(Func select, Func? fallback = null) => + IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; + + public T? FromScalable(Func select, Func? fallback = null) => + IsT2 ? select(AsT2) : fallback != null ? fallback(this) : default; + + public bool IsFree => IsT0; + public bool IsPackaged => IsT1; + public bool IsScalable => IsT2; +} + +[JsonConverter(typeof(FreeOrScalableDTOJsonConverter))] +public class FreeOrScalableDTO(OneOf input) : OneOfBase(input) +{ + public static implicit operator FreeOrScalableDTO(FreeDTO freeDTO) => new(freeDTO); + public static implicit operator FreeOrScalableDTO(ScalableDTO scalableDTO) => new(scalableDTO); + + public T? FromFree(Func select, Func? fallback = null) => + IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; + + public T? FromScalable(Func select, Func? fallback = null) => + IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; + + public bool IsFree => IsT0; + public bool IsScalable => IsT1; +} + +public class FreeDTO +{ + public int Quantity { get; set; } + public string Type => "free"; +} + +public class PackagedDTO +{ + public int Quantity { get; set; } + public string StripePriceId { get; set; } = null!; + public decimal Price { get; set; } + public AdditionalSeats? Additional { get; set; } + public string Type => "packaged"; + + public class AdditionalSeats + { + public string StripePriceId { get; set; } = null!; + public decimal Price { get; set; } + } +} + +public class ScalableDTO +{ + public int Provided { get; set; } + public string StripePriceId { get; set; } = null!; + public decimal Price { get; set; } + public string Type => "scalable"; +} diff --git a/src/Core/Billing/Pricing/PlanAdapter.cs b/src/Core/Billing/Pricing/PlanAdapter.cs index b2b24d4cf9..c38eb0501d 100644 --- a/src/Core/Billing/Pricing/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/PlanAdapter.cs @@ -1,6 +1,6 @@ using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing.Models; using Bit.Core.Models.StaticStore; -using Proto.Billing.Pricing; #nullable enable @@ -8,15 +8,15 @@ namespace Bit.Core.Billing.Pricing; public record PlanAdapter : Plan { - public PlanAdapter(PlanResponse planResponse) + public PlanAdapter(PlanDTO plan) { - Type = ToPlanType(planResponse.LookupKey); + Type = ToPlanType(plan.LookupKey); ProductTier = ToProductTierType(Type); - Name = planResponse.Name; - IsAnnual = !string.IsNullOrEmpty(planResponse.Cadence) && planResponse.Cadence == "annually"; - NameLocalizationKey = planResponse.AdditionalData?["nameLocalizationKey"]; - DescriptionLocalizationKey = planResponse.AdditionalData?["descriptionLocalizationKey"]; - TrialPeriodDays = planResponse.TrialPeriodDays; + Name = plan.Name; + IsAnnual = plan.Cadence is "annually"; + NameLocalizationKey = plan.AdditionalData["nameLocalizationKey"]; + DescriptionLocalizationKey = plan.AdditionalData["descriptionLocalizationKey"]; + TrialPeriodDays = plan.TrialPeriodDays; HasSelfHost = HasFeature("selfHost"); HasPolicies = HasFeature("policies"); HasGroups = HasFeature("groups"); @@ -30,20 +30,20 @@ public record PlanAdapter : Plan HasScim = HasFeature("scim"); HasResetPassword = HasFeature("resetPassword"); UsersGetPremium = HasFeature("usersGetPremium"); - UpgradeSortOrder = planResponse.AdditionalData != null - ? int.Parse(planResponse.AdditionalData["upgradeSortOrder"]) + UpgradeSortOrder = plan.AdditionalData.TryGetValue("upgradeSortOrder", out var upgradeSortOrder) + ? int.Parse(upgradeSortOrder) : 0; - DisplaySortOrder = planResponse.AdditionalData != null - ? int.Parse(planResponse.AdditionalData["displaySortOrder"]) + DisplaySortOrder = plan.AdditionalData.TryGetValue("displaySortOrder", out var displaySortOrder) + ? int.Parse(displaySortOrder) : 0; - HasCustomPermissions = HasFeature("customPermissions"); - Disabled = !planResponse.Available; - PasswordManager = ToPasswordManagerPlanFeatures(planResponse); - SecretsManager = planResponse.SecretsManager != null ? ToSecretsManagerPlanFeatures(planResponse) : null; + Disabled = !plan.Available; + LegacyYear = plan.LegacyYear; + PasswordManager = ToPasswordManagerPlanFeatures(plan); + SecretsManager = plan.SecretsManager != null ? ToSecretsManagerPlanFeatures(plan) : null; return; - bool HasFeature(string lookupKey) => planResponse.Features.Any(feature => feature.LookupKey == lookupKey); + bool HasFeature(string lookupKey) => plan.Features.Any(feature => feature.LookupKey == lookupKey); } #region Mappings @@ -86,29 +86,25 @@ public record PlanAdapter : Plan _ => throw new BillingException() // TODO: Flesh out }; - private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(PlanResponse planResponse) + private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(PlanDTO plan) { - var stripePlanId = GetStripePlanId(planResponse.Seats); - var stripeSeatPlanId = GetStripeSeatPlanId(planResponse.Seats); - var stripeProviderPortalSeatPlanId = planResponse.ManagedSeats?.StripePriceId; - var basePrice = GetBasePrice(planResponse.Seats); - var seatPrice = GetSeatPrice(planResponse.Seats); - var providerPortalSeatPrice = - planResponse.ManagedSeats != null ? decimal.Parse(planResponse.ManagedSeats.Price) : 0; - var scales = planResponse.Seats.KindCase switch - { - PurchasableDTO.KindOneofCase.Scalable => true, - PurchasableDTO.KindOneofCase.Packaged => planResponse.Seats.Packaged.Additional != null, - _ => false - }; - var baseSeats = GetBaseSeats(planResponse.Seats); - var maxSeats = GetMaxSeats(planResponse.Seats); - var baseStorageGb = (short?)planResponse.Storage?.Provided; - var hasAdditionalStorageOption = planResponse.Storage != null; - var stripeStoragePlanId = planResponse.Storage?.StripePriceId; - short? maxCollections = - planResponse.AdditionalData != null && - planResponse.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null; + var stripePlanId = GetStripePlanId(plan.Seats); + var stripeSeatPlanId = GetStripeSeatPlanId(plan.Seats); + var stripeProviderPortalSeatPlanId = plan.ManagedSeats?.StripePriceId; + var basePrice = GetBasePrice(plan.Seats); + var seatPrice = GetSeatPrice(plan.Seats); + var providerPortalSeatPrice = plan.ManagedSeats?.Price ?? 0; + var scales = plan.Seats.Match( + _ => false, + packaged => packaged.Additional != null, + _ => true); + var baseSeats = GetBaseSeats(plan.Seats); + var maxSeats = GetMaxSeats(plan.Seats); + var baseStorageGb = (short?)plan.Storage?.Provided; + var hasAdditionalStorageOption = plan.Storage != null; + var additionalStoragePricePerGb = plan.Storage?.Price ?? 0; + var stripeStoragePlanId = plan.Storage?.StripePriceId; + short? maxCollections = plan.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null; return new PasswordManagerPlanFeatures { @@ -124,30 +120,29 @@ public record PlanAdapter : Plan MaxSeats = maxSeats, BaseStorageGb = baseStorageGb, HasAdditionalStorageOption = hasAdditionalStorageOption, + AdditionalStoragePricePerGb = additionalStoragePricePerGb, StripeStoragePlanId = stripeStoragePlanId, MaxCollections = maxCollections }; } - private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(PlanResponse planResponse) + private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(PlanDTO plan) { - var seats = planResponse.SecretsManager.Seats; - var serviceAccounts = planResponse.SecretsManager.ServiceAccounts; + var seats = plan.SecretsManager!.Seats; + var serviceAccounts = plan.SecretsManager.ServiceAccounts; var maxServiceAccounts = GetMaxServiceAccounts(serviceAccounts); - var allowServiceAccountsAutoscale = serviceAccounts.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var allowServiceAccountsAutoscale = serviceAccounts.IsScalable; var stripeServiceAccountPlanId = GetStripeServiceAccountPlanId(serviceAccounts); var additionalPricePerServiceAccount = GetAdditionalPricePerServiceAccount(serviceAccounts); var baseServiceAccount = GetBaseServiceAccount(serviceAccounts); - var hasAdditionalServiceAccountOption = serviceAccounts.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var hasAdditionalServiceAccountOption = serviceAccounts.IsScalable; var stripeSeatPlanId = GetStripeSeatPlanId(seats); - var hasAdditionalSeatsOption = seats.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var hasAdditionalSeatsOption = seats.IsScalable; var seatPrice = GetSeatPrice(seats); var maxSeats = GetMaxSeats(seats); - var allowSeatAutoscale = seats.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; - var maxProjects = - planResponse.AdditionalData != null && - planResponse.AdditionalData.TryGetValue("secretsManager.maxProjects", out var value) ? short.Parse(value) : 0; + var allowSeatAutoscale = seats.IsScalable; + var maxProjects = plan.AdditionalData.TryGetValue("secretsManager.maxProjects", out var value) ? short.Parse(value) : 0; return new SecretsManagerPlanFeatures { @@ -167,66 +162,54 @@ public record PlanAdapter : Plan } private static decimal? GetAdditionalPricePerServiceAccount(FreeOrScalableDTO freeOrScalable) - => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable - ? null - : decimal.Parse(freeOrScalable.Scalable.Price); + => freeOrScalable.FromScalable(x => x.Price); private static decimal GetBasePrice(PurchasableDTO purchasable) - => purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? 0 : decimal.Parse(purchasable.Packaged.Price); + => purchasable.FromPackaged(x => x.Price); private static int GetBaseSeats(PurchasableDTO purchasable) - => purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? 0 : purchasable.Packaged.Quantity; + => purchasable.FromPackaged(x => x.Quantity); private static short GetBaseServiceAccount(FreeOrScalableDTO freeOrScalable) - => freeOrScalable.KindCase switch - { - FreeOrScalableDTO.KindOneofCase.Free => (short)freeOrScalable.Free.Quantity, - FreeOrScalableDTO.KindOneofCase.Scalable => (short)freeOrScalable.Scalable.Provided, - _ => 0 - }; + => freeOrScalable.Match( + free => (short)free.Quantity, + scalable => (short)scalable.Provided); private static short? GetMaxSeats(PurchasableDTO purchasable) - => purchasable.KindCase != PurchasableDTO.KindOneofCase.Free ? null : (short)purchasable.Free.Quantity; + => purchasable.Match( + free => (short)free.Quantity, + packaged => (short)packaged.Quantity, + _ => null); private static short? GetMaxSeats(FreeOrScalableDTO freeOrScalable) - => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Free ? null : (short)freeOrScalable.Free.Quantity; + => freeOrScalable.FromFree(x => (short)x.Quantity); private static short? GetMaxServiceAccounts(FreeOrScalableDTO freeOrScalable) - => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Free ? null : (short)freeOrScalable.Free.Quantity; + => freeOrScalable.FromFree(x => (short)x.Quantity); private static decimal GetSeatPrice(PurchasableDTO purchasable) - => purchasable.KindCase switch - { - PurchasableDTO.KindOneofCase.Packaged => purchasable.Packaged.Additional != null ? decimal.Parse(purchasable.Packaged.Additional.Price) : 0, - PurchasableDTO.KindOneofCase.Scalable => decimal.Parse(purchasable.Scalable.Price), - _ => 0 - }; + => purchasable.Match( + _ => 0, + packaged => packaged.Additional?.Price ?? 0, + scalable => scalable.Price); private static decimal GetSeatPrice(FreeOrScalableDTO freeOrScalable) - => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable - ? 0 - : decimal.Parse(freeOrScalable.Scalable.Price); + => freeOrScalable.FromScalable(x => x.Price); private static string? GetStripePlanId(PurchasableDTO purchasable) - => purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? null : purchasable.Packaged.StripePriceId; + => purchasable.FromPackaged(x => x.StripePriceId); private static string? GetStripeSeatPlanId(PurchasableDTO purchasable) - => purchasable.KindCase switch - { - PurchasableDTO.KindOneofCase.Packaged => purchasable.Packaged.Additional?.StripePriceId, - PurchasableDTO.KindOneofCase.Scalable => purchasable.Scalable.StripePriceId, - _ => null - }; + => purchasable.Match( + _ => null, + packaged => packaged.Additional?.StripePriceId, + scalable => scalable.StripePriceId); private static string? GetStripeSeatPlanId(FreeOrScalableDTO freeOrScalable) - => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable - ? null - : freeOrScalable.Scalable.StripePriceId; + => freeOrScalable.FromScalable(x => x.StripePriceId); private static string? GetStripeServiceAccountPlanId(FreeOrScalableDTO freeOrScalable) - => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable - ? null - : freeOrScalable.Scalable.StripePriceId; + => freeOrScalable.FromScalable(x => x.StripePriceId); #endregion } diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index 65fc1761ad..14caa54eb4 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -1,12 +1,13 @@ -using Bit.Core.Billing.Enums; -using Bit.Core.Models.StaticStore; +using System.Net; +using System.Net.Http.Json; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing.Models; +using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; -using Google.Protobuf.WellKnownTypes; -using Grpc.Core; -using Grpc.Net.Client; -using Proto.Billing.Pricing; +using Microsoft.Extensions.Logging; +using Plan = Bit.Core.Models.StaticStore.Plan; #nullable enable @@ -14,10 +15,17 @@ namespace Bit.Core.Billing.Pricing; public class PricingClient( IFeatureService featureService, - GlobalSettings globalSettings) : IPricingClient + GlobalSettings globalSettings, + HttpClient httpClient, + ILogger logger) : IPricingClient { public async Task GetPlan(PlanType planType) { + if (globalSettings.SelfHosted) + { + return null; + } + var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); if (!usePricingService) @@ -25,30 +33,55 @@ public class PricingClient( return StaticStore.GetPlan(planType); } - using var channel = GrpcChannel.ForAddress(globalSettings.PricingUri); - var client = new PasswordManager.PasswordManagerClient(channel); + var lookupKey = GetLookupKey(planType); - var lookupKey = ToLookupKey(planType); - if (string.IsNullOrEmpty(lookupKey)) + if (lookupKey == null) { + logger.LogError("Could not find Pricing Service lookup key for PlanType {PlanType}", planType); return null; } - try - { - var response = - await client.GetPlanByLookupKeyAsync(new GetPlanByLookupKeyRequest { LookupKey = lookupKey }); + var response = await httpClient.GetAsync($"plans/lookup/{lookupKey}"); - return new PlanAdapter(response); - } - catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.NotFound) + if (response.IsSuccessStatusCode) { + var plan = await response.Content.ReadFromJsonAsync(); + if (plan == null) + { + throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); + } + return new PlanAdapter(plan); + } + + if (response.StatusCode == HttpStatusCode.NotFound) + { + logger.LogError("Pricing Service plan for PlanType {PlanType} was not found", planType); return null; } + + throw new BillingException( + message: $"Request to the Pricing Service failed with status code {response.StatusCode}"); + } + + public async Task GetPlanOrThrow(PlanType planType) + { + var plan = await GetPlan(planType); + + if (plan == null) + { + throw new NotFoundException(); + } + + return plan; } public async Task> ListPlans() { + if (globalSettings.SelfHosted) + { + return []; + } + var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); if (!usePricingService) @@ -56,14 +89,23 @@ public class PricingClient( return StaticStore.Plans.ToList(); } - using var channel = GrpcChannel.ForAddress(globalSettings.PricingUri); - var client = new PasswordManager.PasswordManagerClient(channel); + var response = await httpClient.GetAsync("plans"); - var response = await client.ListPlansAsync(new Empty()); - return response.Plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList(); + if (response.IsSuccessStatusCode) + { + var plans = await response.Content.ReadFromJsonAsync>(); + if (plans == null) + { + throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); + } + return plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList(); + } + + throw new BillingException( + message: $"Request to the Pricing Service failed with status {response.StatusCode}"); } - private static string? ToLookupKey(PlanType planType) + private static string? GetLookupKey(PlanType planType) => planType switch { PlanType.EnterpriseAnnually => "enterprise-annually", diff --git a/src/Core/Billing/Pricing/Protos/password-manager.proto b/src/Core/Billing/Pricing/Protos/password-manager.proto deleted file mode 100644 index 69a4c51bd1..0000000000 --- a/src/Core/Billing/Pricing/Protos/password-manager.proto +++ /dev/null @@ -1,92 +0,0 @@ -syntax = "proto3"; - -option csharp_namespace = "Proto.Billing.Pricing"; - -package plans; - -import "google/protobuf/empty.proto"; -import "google/protobuf/struct.proto"; -import "google/protobuf/wrappers.proto"; - -service PasswordManager { - rpc GetPlanByLookupKey (GetPlanByLookupKeyRequest) returns (PlanResponse); - rpc ListPlans (google.protobuf.Empty) returns (ListPlansResponse); -} - -// Requests -message GetPlanByLookupKeyRequest { - string lookupKey = 1; -} - -// Responses -message PlanResponse { - string name = 1; - string lookupKey = 2; - string tier = 4; - optional string cadence = 6; - optional google.protobuf.Int32Value legacyYear = 8; - bool available = 9; - repeated FeatureDTO features = 10; - PurchasableDTO seats = 11; - optional ScalableDTO managedSeats = 12; - optional ScalableDTO storage = 13; - optional SecretsManagerPurchasablesDTO secretsManager = 14; - optional google.protobuf.Int32Value trialPeriodDays = 15; - repeated string canUpgradeTo = 16; - map additionalData = 17; -} - -message ListPlansResponse { - repeated PlanResponse plans = 1; -} - -// DTOs -message FeatureDTO { - string name = 1; - string lookupKey = 2; -} - -message FreeDTO { - int32 quantity = 2; - string type = 4; -} - -message PackagedDTO { - message AdditionalSeats { - string stripePriceId = 1; - string price = 2; - } - - int32 quantity = 2; - string stripePriceId = 3; - string price = 4; - optional AdditionalSeats additional = 5; - string type = 6; -} - -message ScalableDTO { - int32 provided = 2; - string stripePriceId = 6; - string price = 7; - string type = 9; -} - -message PurchasableDTO { - oneof kind { - FreeDTO free = 1; - PackagedDTO packaged = 2; - ScalableDTO scalable = 3; - } -} - -message FreeOrScalableDTO { - oneof kind { - FreeDTO free = 1; - ScalableDTO scalable = 2; - } -} - -message SecretsManagerPurchasablesDTO { - FreeOrScalableDTO seats = 1; - FreeOrScalableDTO serviceAccounts = 2; -} diff --git a/src/Core/Billing/Pricing/ServiceCollectionExtensions.cs b/src/Core/Billing/Pricing/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..465a12de14 --- /dev/null +++ b/src/Core/Billing/Pricing/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Bit.Core.Settings; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Billing.Pricing; + +public static class ServiceCollectionExtensions +{ + public static void AddPricingClient(this IServiceCollection services) + { + services.AddHttpClient((serviceProvider, httpClient) => + { + var globalSettings = serviceProvider.GetRequiredService(); + if (string.IsNullOrEmpty(globalSettings.PricingUri)) + { + return; + } + httpClient.BaseAddress = new Uri(globalSettings.PricingUri); + httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + }); + } +} diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 57a4b1781b..8b773f1cef 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -3,12 +3,12 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Pricing; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Utilities; using Braintree; using Microsoft.Extensions.Logging; using Stripe; @@ -26,6 +26,7 @@ public class OrganizationBillingService( IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, + IPricingClient pricingClient, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, @@ -63,13 +64,22 @@ public class OrganizationBillingService( return null; } - var isEligibleForSelfHost = IsEligibleForSelfHost(organization); + if (globalSettings.SelfHosted) + { + return OrganizationMetadata.Default; + } + + var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization); + var isManaged = organization.Status == OrganizationStatusType.Managed; if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { - return new OrganizationMetadata(isEligibleForSelfHost, isManaged, false, - false, false, false, false, null, null, null); + return OrganizationMetadata.Default with + { + IsEligibleForSelfHost = isEligibleForSelfHost, + IsManaged = isManaged + }; } var customer = await subscriberService.GetCustomer(organization, @@ -77,18 +87,21 @@ public class OrganizationBillingService( var subscription = await subscriberService.GetSubscription(organization); - var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription); - var isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription); - var isSubscriptionCanceled = IsSubscriptionCanceled(subscription); - var hasSubscription = true; - var openInvoice = await HasOpenInvoiceAsync(subscription); - var hasOpenInvoice = openInvoice.HasOpenInvoice; - var invoiceDueDate = openInvoice.DueDate; - var invoiceCreatedDate = openInvoice.CreatedDate; - var subPeriodEndDate = subscription?.CurrentPeriodEnd; + var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); - return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone, - isSubscriptionUnpaid, hasSubscription, hasOpenInvoice, isSubscriptionCanceled, invoiceDueDate, invoiceCreatedDate, subPeriodEndDate); + var invoice = await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()); + + return new OrganizationMetadata( + isEligibleForSelfHost, + isManaged, + isOnSecretsManagerStandalone, + subscription.Status == StripeConstants.SubscriptionStatus.Unpaid, + true, + invoice?.Status == StripeConstants.InvoiceStatus.Open, + subscription.Status == StripeConstants.SubscriptionStatus.Canceled, + invoice?.DueDate, + invoice?.Created, + subscription.CurrentPeriodEnd); } public async Task UpdatePaymentMethod( @@ -299,7 +312,7 @@ public class OrganizationBillingService( Customer customer, SubscriptionSetup subscriptionSetup) { - var plan = subscriptionSetup.Plan; + var plan = await pricingClient.GetPlanOrThrow(subscriptionSetup.PlanType); var passwordManagerOptions = subscriptionSetup.PasswordManagerOptions; @@ -385,15 +398,17 @@ public class OrganizationBillingService( return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); } - private static bool IsEligibleForSelfHost( + private async Task IsEligibleForSelfHostAsync( Organization organization) { - var eligibleSelfHostPlans = StaticStore.Plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type); + var plans = await pricingClient.ListPlans(); + + var eligibleSelfHostPlans = plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type); return eligibleSelfHostPlans.Contains(organization.PlanType); } - private static bool IsOnSecretsManagerStandalone( + private async Task IsOnSecretsManagerStandalone( Organization organization, Customer? customer, Subscription? subscription) @@ -403,7 +418,7 @@ public class OrganizationBillingService( return false; } - var plan = StaticStore.GetPlan(organization.PlanType); + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); if (!plan.SupportsSecretsManager) { @@ -424,38 +439,5 @@ public class OrganizationBillingService( return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } - private static bool IsSubscriptionUnpaid(Subscription subscription) - { - if (subscription == null) - { - return false; - } - - return subscription.Status == "unpaid"; - } - - private async Task<(bool HasOpenInvoice, DateTime? CreatedDate, DateTime? DueDate)> HasOpenInvoiceAsync(Subscription subscription) - { - if (subscription?.LatestInvoiceId == null) - { - return (false, null, null); - } - - var invoice = await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()); - - return invoice?.Status == "open" - ? (true, invoice.Created, invoice.DueDate) - : (false, null, null); - } - - private static bool IsSubscriptionCanceled(Subscription subscription) - { - if (subscription == null) - { - return false; - } - - return subscription.Status == "canceled"; - } #endregion } diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 860cf33298..ff5e929f18 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -27,12 +27,6 @@ - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - @@ -78,11 +72,7 @@ - - - - - + diff --git a/src/Core/Models/Business/CompleteSubscriptionUpdate.cs b/src/Core/Models/Business/CompleteSubscriptionUpdate.cs index aa1c92dc2e..1d983404af 100644 --- a/src/Core/Models/Business/CompleteSubscriptionUpdate.cs +++ b/src/Core/Models/Business/CompleteSubscriptionUpdate.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Exceptions; using Stripe; +using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Core.Models.Business; @@ -9,7 +10,7 @@ namespace Bit.Core.Models.Business; /// public class SubscriptionData { - public StaticStore.Plan Plan { get; init; } + public Plan Plan { get; init; } public int PurchasedPasswordManagerSeats { get; init; } public bool SubscribedToSecretsManager { get; set; } public int? PurchasedSecretsManagerSeats { get; init; } @@ -38,22 +39,24 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate /// in the case of an error. /// /// The to upgrade. + /// The organization's plan. /// The updates you want to apply to the organization's subscription. public CompleteSubscriptionUpdate( Organization organization, + Plan plan, SubscriptionData updatedSubscription) { - _currentSubscription = GetSubscriptionDataFor(organization); + _currentSubscription = GetSubscriptionDataFor(organization, plan); _updatedSubscription = updatedSubscription; } - protected override List PlanIds => new() - { + protected override List PlanIds => + [ GetPasswordManagerPlanId(_updatedSubscription.Plan), _updatedSubscription.Plan.SecretsManager.StripeSeatPlanId, _updatedSubscription.Plan.SecretsManager.StripeServiceAccountPlanId, _updatedSubscription.Plan.PasswordManager.StripeStoragePlanId - }; + ]; /// /// Generates the necessary to revert an 's @@ -94,7 +97,7 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate */ /// /// Checks whether the updates provided in the 's constructor - /// are actually different than the organization's current . + /// are actually different from the organization's current . /// /// The organization's . public override bool UpdateNeeded(Subscription subscription) @@ -278,11 +281,8 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate }; } - private static SubscriptionData GetSubscriptionDataFor(Organization organization) - { - var plan = Utilities.StaticStore.GetPlan(organization.PlanType); - - return new SubscriptionData + private static SubscriptionData GetSubscriptionDataFor(Organization organization, Plan plan) + => new() { Plan = plan, PurchasedPasswordManagerSeats = organization.Seats.HasValue @@ -299,5 +299,4 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate ? organization.MaxStorageGb.Value - (plan.PasswordManager.BaseStorageGb ?? 0) : 0 }; - } } diff --git a/src/Core/Models/Business/ProviderSubscriptionUpdate.cs b/src/Core/Models/Business/ProviderSubscriptionUpdate.cs index d66013ad14..1fd833ca1f 100644 --- a/src/Core/Models/Business/ProviderSubscriptionUpdate.cs +++ b/src/Core/Models/Business/ProviderSubscriptionUpdate.cs @@ -2,6 +2,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Stripe; +using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Core.Models.Business; @@ -14,18 +15,16 @@ public class ProviderSubscriptionUpdate : SubscriptionUpdate protected override List PlanIds => [_planId]; public ProviderSubscriptionUpdate( - PlanType planType, + Plan plan, int previouslyPurchasedSeats, int newlyPurchasedSeats) { - if (!planType.SupportsConsolidatedBilling()) + if (!plan.Type.SupportsConsolidatedBilling()) { throw new BillingException( message: $"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing"); } - var plan = Utilities.StaticStore.GetPlan(planType); - _planId = plan.PasswordManager.StripeProviderPortalSeatPlanId; _previouslyPurchasedSeats = previouslyPurchasedSeats; _newlyPurchasedSeats = newlyPurchasedSeats; diff --git a/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs index 9a4fcac034..d85925db34 100644 --- a/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs +++ b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs @@ -7,6 +7,7 @@ namespace Bit.Core.Models.Business; public class SecretsManagerSubscriptionUpdate { public Organization Organization { get; } + public Plan Plan { get; } /// /// The total seats the organization will have after the update, including any base seats included in the plan @@ -49,21 +50,16 @@ public class SecretsManagerSubscriptionUpdate public bool MaxAutoscaleSmSeatsChanged => MaxAutoscaleSmSeats != Organization.MaxAutoscaleSmSeats; public bool MaxAutoscaleSmServiceAccountsChanged => MaxAutoscaleSmServiceAccounts != Organization.MaxAutoscaleSmServiceAccounts; - public Plan Plan => Utilities.StaticStore.GetPlan(Organization.PlanType); public bool SmSeatAutoscaleLimitReached => SmSeats.HasValue && MaxAutoscaleSmSeats.HasValue && SmSeats == MaxAutoscaleSmSeats; public bool SmServiceAccountAutoscaleLimitReached => SmServiceAccounts.HasValue && MaxAutoscaleSmServiceAccounts.HasValue && SmServiceAccounts == MaxAutoscaleSmServiceAccounts; - public SecretsManagerSubscriptionUpdate(Organization organization, bool autoscaling) + public SecretsManagerSubscriptionUpdate(Organization organization, Plan plan, bool autoscaling) { - if (organization == null) - { - throw new NotFoundException("Organization is not found."); - } - - Organization = organization; + Organization = organization ?? throw new NotFoundException("Organization is not found."); + Plan = plan; if (!Plan.SupportsSecretsManager) { diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index 5294097613..c9c3b31c7f 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -82,7 +82,6 @@ public class SubscriptionInfo } public bool AddonSubscriptionItem { get; set; } - public string ProductId { get; set; } public string Name { get; set; } public decimal Amount { get; set; } diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/CloudSyncSponsorshipsCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/CloudSyncSponsorshipsCommand.cs index f817ef7d2e..2756f8930b 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/CloudSyncSponsorshipsCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/CloudSyncSponsorshipsCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -54,8 +55,9 @@ public class CloudSyncSponsorshipsCommand : ICloudSyncSponsorshipsCommand foreach (var selfHostedSponsorship in sponsorshipsData) { var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(selfHostedSponsorship.PlanSponsorshipType)?.SponsoringProductTierType; + var sponsoringOrgProductTier = sponsoringOrg.PlanType.GetProductTier(); if (requiredSponsoringProductType == null - || StaticStore.GetPlan(sponsoringOrg.PlanType).ProductTier != requiredSponsoringProductType.Value) + || sponsoringOrgProductTier != requiredSponsoringProductType.Value) { continue; // prevent unsupported sponsorships } diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs index e8d43fd6a9..a54106481c 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -50,9 +51,10 @@ public class SetUpSponsorshipCommand : ISetUpSponsorshipCommand // Check org to sponsor's product type var requiredSponsoredProductType = StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value)?.SponsoredProductTierType; + var sponsoredOrganizationProductTier = sponsoredOrganization.PlanType.GetProductTier(); + if (requiredSponsoredProductType == null || - sponsoredOrganization == null || - StaticStore.GetPlan(sponsoredOrganization.PlanType).ProductTier != requiredSponsoredProductType.Value) + sponsoredOrganizationProductTier != requiredSponsoredProductType.Value) { throw new BadRequestException("Can only redeem sponsorship offer on families organizations."); } diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs index 214786c0ae..a7423b067e 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; @@ -103,8 +104,6 @@ public class ValidateSponsorshipCommand : CancelSponsorshipCommand, IValidateSpo return false; } - var sponsoringOrgPlan = Utilities.StaticStore.GetPlan(sponsoringOrganization.PlanType); - if (OrgDisabledForMoreThanGracePeriod(sponsoringOrganization)) { _logger.LogWarning("Sponsoring Organization {SponsoringOrganizationId} is disabled for more than 3 months.", sponsoringOrganization.Id); @@ -113,7 +112,9 @@ public class ValidateSponsorshipCommand : CancelSponsorshipCommand, IValidateSpo return false; } - if (sponsoredPlan.SponsoringProductTierType != sponsoringOrgPlan.ProductTier) + var sponsoringOrgProductTier = sponsoringOrganization.PlanType.GetProductTier(); + + if (sponsoredPlan.SponsoringProductTierType != sponsoringOrgProductTier) { _logger.LogWarning("Sponsoring Organization {SponsoringOrganizationId} is not on the required product type.", sponsoringOrganization.Id); await CancelSponsorshipAsync(sponsoredOrganization, existingSponsorship); diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs index a00dae2a9d..ac65d3b897 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -31,9 +32,10 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand } var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(sponsorshipType)?.SponsoringProductTierType; + var sponsoringOrgProductTier = sponsoringOrg.PlanType.GetProductTier(); + if (requiredSponsoringProductType == null || - sponsoringOrg == null || - StaticStore.GetPlan(sponsoringOrg.PlanType).ProductTier != requiredSponsoringProductType.Value) + sponsoringOrgProductTier != requiredSponsoringProductType.Value) { throw new BadRequestException("Specified Organization cannot sponsor other organizations."); } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs index 08cd09e5c3..a0ce7c03b9 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs @@ -2,11 +2,11 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Services; -using Bit.Core.Utilities; namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions; @@ -15,22 +15,25 @@ public class AddSecretsManagerSubscriptionCommand : IAddSecretsManagerSubscripti private readonly IPaymentService _paymentService; private readonly IOrganizationService _organizationService; private readonly IProviderRepository _providerRepository; + private readonly IPricingClient _pricingClient; public AddSecretsManagerSubscriptionCommand( IPaymentService paymentService, IOrganizationService organizationService, - IProviderRepository providerRepository) + IProviderRepository providerRepository, + IPricingClient pricingClient) { _paymentService = paymentService; _organizationService = organizationService; _providerRepository = providerRepository; + _pricingClient = pricingClient; } public async Task SignUpAsync(Organization organization, int additionalSmSeats, int additionalServiceAccounts) { await ValidateOrganization(organization); - var plan = StaticStore.GetPlan(organization.PlanType); + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); var signup = SetOrganizationUpgrade(organization, additionalSmSeats, additionalServiceAccounts); _organizationService.ValidateSecretsManagerPlan(plan, signup); @@ -73,7 +76,13 @@ public class AddSecretsManagerSubscriptionCommand : IAddSecretsManagerSubscripti throw new BadRequestException("Organization already uses Secrets Manager."); } - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType && p.SupportsSecretsManager); + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + + if (!plan.SupportsSecretsManager) + { + throw new BadRequestException("Organization's plan does not support Secrets Manager."); + } + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId) && plan.ProductTier != ProductTierType.Free) { throw new BadRequestException("No payment method found."); diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 19af8121e7..09b766e885 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -6,6 +6,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; @@ -18,7 +19,6 @@ using Bit.Core.Services; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; -using Bit.Core.Utilities; namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions; @@ -38,6 +38,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand private readonly IOrganizationService _organizationService; private readonly IFeatureService _featureService; private readonly IOrganizationBillingService _organizationBillingService; + private readonly IPricingClient _pricingClient; public UpgradeOrganizationPlanCommand( IOrganizationUserRepository organizationUserRepository, @@ -53,7 +54,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand IOrganizationRepository organizationRepository, IOrganizationService organizationService, IFeatureService featureService, - IOrganizationBillingService organizationBillingService) + IOrganizationBillingService organizationBillingService, + IPricingClient pricingClient) { _organizationUserRepository = organizationUserRepository; _collectionRepository = collectionRepository; @@ -69,6 +71,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand _organizationService = organizationService; _featureService = featureService; _organizationBillingService = organizationBillingService; + _pricingClient = pricingClient; } public async Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade) @@ -84,14 +87,11 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand throw new BadRequestException("Your account has no payment method available."); } - var existingPlan = StaticStore.GetPlan(organization.PlanType); - if (existingPlan == null) - { - throw new BadRequestException("Existing plan not found."); - } + var existingPlan = await _pricingClient.GetPlanOrThrow(organization.PlanType); - var newPlan = StaticStore.Plans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); - if (newPlan == null) + var newPlan = await _pricingClient.GetPlanOrThrow(upgrade.Plan); + + if (newPlan.Disabled) { throw new BadRequestException("Plan not found."); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 7f2ac36216..1a8fe2085d 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -7,6 +7,7 @@ using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Models.Api.Requests.Organizations; using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -37,6 +38,7 @@ public class StripePaymentService : IPaymentService private readonly IFeatureService _featureService; private readonly ITaxService _taxService; private readonly ISubscriberService _subscriberService; + private readonly IPricingClient _pricingClient; public StripePaymentService( ITransactionRepository transactionRepository, @@ -46,7 +48,8 @@ public class StripePaymentService : IPaymentService IGlobalSettings globalSettings, IFeatureService featureService, ITaxService taxService, - ISubscriberService subscriberService) + ISubscriberService subscriberService, + IPricingClient pricingClient) { _transactionRepository = transactionRepository; _logger = logger; @@ -56,6 +59,7 @@ public class StripePaymentService : IPaymentService _featureService = featureService; _taxService = taxService; _subscriberService = subscriberService; + _pricingClient = pricingClient; } public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, @@ -297,7 +301,7 @@ public class StripePaymentService : IPaymentService OrganizationSponsorship sponsorship, bool applySponsorship) { - var existingPlan = Utilities.StaticStore.GetPlan(org.PlanType); + var existingPlan = await _pricingClient.GetPlanOrThrow(org.PlanType); var sponsoredPlan = sponsorship?.PlanSponsorshipType != null ? Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value) : null; @@ -887,18 +891,21 @@ public class StripePaymentService : IPaymentService return paymentIntentClientSecret; } - public Task AdjustSubscription( + public async Task AdjustSubscription( Organization organization, StaticStore.Plan updatedPlan, int newlyPurchasedPasswordManagerSeats, bool subscribedToSecretsManager, int? newlyPurchasedSecretsManagerSeats, int? newlyPurchasedAdditionalSecretsManagerServiceAccounts, - int newlyPurchasedAdditionalStorage) => - FinalizeSubscriptionChangeAsync( + int newlyPurchasedAdditionalStorage) + { + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + return await FinalizeSubscriptionChangeAsync( organization, new CompleteSubscriptionUpdate( organization, + plan, new SubscriptionData { Plan = updatedPlan, @@ -909,6 +916,7 @@ public class StripePaymentService : IPaymentService newlyPurchasedAdditionalSecretsManagerServiceAccounts, PurchasedAdditionalStorage = newlyPurchasedAdditionalStorage }), true); + } public Task AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) => FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats)); @@ -921,7 +929,7 @@ public class StripePaymentService : IPaymentService => FinalizeSubscriptionChangeAsync( provider, new ProviderSubscriptionUpdate( - plan.Type, + plan, currentlySubscribedSeats, newlySubscribedSeats)); @@ -1957,7 +1965,7 @@ public class StripePaymentService : IPaymentService string gatewayCustomerId, string gatewaySubscriptionId) { - var plan = Utilities.StaticStore.GetPlan(parameters.PasswordManager.Plan); + var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan); var options = new InvoiceCreatePreviewOptions { diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index 78fcd0d99f..24e9ccd7bd 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -137,6 +138,7 @@ public static class StaticStore } public static IDictionary> GlobalDomains { get; set; } + [Obsolete("Use PricingClient.ListPlans to retrieve all plans.")] public static IEnumerable Plans { get; } public static IEnumerable SponsoredPlans { get; set; } = new[] { @@ -147,10 +149,11 @@ public static class StaticStore SponsoringProductTierType = ProductTierType.Enterprise, StripePlanId = "2021-family-for-enterprise-annually", UsersCanSponsor = (OrganizationUserOrganizationDetails org) => - GetPlan(org.PlanType).ProductTier == ProductTierType.Enterprise, + org.PlanType.GetProductTier() == ProductTierType.Enterprise, } }; + [Obsolete("Use PricingClient.GetPlan to retrieve a plan.")] public static Plan GetPlan(PlanType planType) => Plans.SingleOrDefault(p => p.Type == planType); public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) => @@ -167,6 +170,7 @@ public static class StaticStore /// public static bool IsAddonSubscriptionItem(string stripePlanId) { + // TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-16844 return Plans.Any(p => p.PasswordManager.StripeStoragePlanId == stripePlanId || (p.SecretsManager?.StripeServiceAccountPlanId == stripePlanId)); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index b739469c78..b0906ddc43 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -17,6 +17,7 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -54,6 +55,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand; + private readonly IPricingClient _pricingClient; private readonly OrganizationsController _sut; public OrganizationsControllerTests() @@ -78,6 +80,7 @@ public class OrganizationsControllerTests : IDisposable _removeOrganizationUserCommand = Substitute.For(); _cloudOrganizationSignUpCommand = Substitute.For(); _organizationDeleteCommand = Substitute.For(); + _pricingClient = Substitute.For(); _sut = new OrganizationsController( _organizationRepository, @@ -99,7 +102,8 @@ public class OrganizationsControllerTests : IDisposable _orgDeleteTokenDataFactory, _removeOrganizationUserCommand, _cloudOrganizationSignUpCommand, - _organizationDeleteCommand); + _organizationDeleteCommand, + _pricingClient); } public void Dispose() diff --git a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs index 483d962830..16e32870ad 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -49,6 +50,7 @@ public class OrganizationsControllerTests : IDisposable private readonly ISubscriberService _subscriberService; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IOrganizationInstallationRepository _organizationInstallationRepository; + private readonly IPricingClient _pricingClient; private readonly OrganizationsController _sut; @@ -73,6 +75,7 @@ public class OrganizationsControllerTests : IDisposable _subscriberService = Substitute.For(); _removeOrganizationUserCommand = Substitute.For(); _organizationInstallationRepository = Substitute.For(); + _pricingClient = Substitute.For(); _sut = new OrganizationsController( _organizationRepository, @@ -89,7 +92,8 @@ public class OrganizationsControllerTests : IDisposable _addSecretsManagerSubscriptionCommand, _referenceEventService, _subscriberService, - _organizationInstallationRepository); + _organizationInstallationRepository, + _pricingClient); } public void Dispose() diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index 644303c873..df84f74d11 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -331,6 +332,11 @@ public class ProviderBillingControllerTests sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + foreach (var providerPlan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(providerPlan.PlanType).Returns(StaticStore.GetPlan(providerPlan.PlanType)); + } + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); Assert.IsType>(result); diff --git a/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs index 731494a846..8147b81240 100644 --- a/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs +++ b/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs @@ -2,6 +2,7 @@ using Bit.Api.SecretsManager.Controllers; using Bit.Api.SecretsManager.Models.Request; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -15,6 +16,7 @@ using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Queries.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -119,6 +121,8 @@ public class ServiceAccountsControllerTests { ArrangeCreateServiceAccountAutoScalingTest(newSlotsRequired, sutProvider, data, organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); + await sutProvider.Sut.CreateAsync(organization.Id, data); await sutProvider.GetDependency().Received(1) diff --git a/test/Billing.Test/Services/ProviderEventServiceTests.cs b/test/Billing.Test/Services/ProviderEventServiceTests.cs index 31f4ec8969..e080dd8288 100644 --- a/test/Billing.Test/Services/ProviderEventServiceTests.cs +++ b/test/Billing.Test/Services/ProviderEventServiceTests.cs @@ -1,14 +1,16 @@ using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Billing.Test.Utilities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Enums; +using Bit.Core.Repositories; using Bit.Core.Utilities; -using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; @@ -17,6 +19,12 @@ namespace Bit.Billing.Test.Services; public class ProviderEventServiceTests { + private readonly IOrganizationRepository _organizationRepository = + Substitute.For(); + + private readonly IPricingClient _pricingClient = + Substitute.For(); + private readonly IProviderInvoiceItemRepository _providerInvoiceItemRepository = Substitute.For(); @@ -37,7 +45,8 @@ public class ProviderEventServiceTests public ProviderEventServiceTests() { _providerEventService = new ProviderEventService( - Substitute.For>(), + _organizationRepository, + _pricingClient, _providerInvoiceItemRepository, _providerOrganizationRepository, _providerPlanRepository, @@ -147,6 +156,12 @@ public class ProviderEventServiceTests _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId).Returns(clients); + _organizationRepository.GetByIdAsync(client1Id) + .Returns(new Organization { PlanType = PlanType.TeamsMonthly }); + + _organizationRepository.GetByIdAsync(client2Id) + .Returns(new Organization { PlanType = PlanType.EnterpriseMonthly }); + var providerPlans = new List { new () @@ -169,6 +184,11 @@ public class ProviderEventServiceTests } }; + foreach (var providerPlan in providerPlans) + { + _pricingClient.GetPlanOrThrow(providerPlan.PlanType).Returns(StaticStore.GetPlan(providerPlan.PlanType)); + } + _providerPlanRepository.GetByProviderId(providerId).Returns(providerPlans); // Act diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs index 859c74f3d0..544c97d166 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -38,6 +39,8 @@ public class CloudICloudOrganizationSignUpCommandTests signup.IsFromSecretsManagerTrial = false; signup.IsFromProvider = false; + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(StaticStore.GetPlan(signup.Plan)); + var result = await sutProvider.Sut.SignUpOrganizationAsync(signup); await sutProvider.GetDependency().Received(1).CreateAsync( @@ -66,7 +69,7 @@ public class CloudICloudOrganizationSignUpCommandTests sale.CustomerSetup.TokenizedPaymentSource.Token == signup.PaymentToken && sale.CustomerSetup.TaxInformation.Country == signup.TaxInfo.BillingAddressCountry && sale.CustomerSetup.TaxInformation.PostalCode == signup.TaxInfo.BillingAddressPostalCode && - sale.SubscriptionSetup.Plan == plan && + sale.SubscriptionSetup.PlanType == plan.Type && sale.SubscriptionSetup.PasswordManagerOptions.Seats == signup.AdditionalSeats && sale.SubscriptionSetup.PasswordManagerOptions.Storage == signup.AdditionalStorageGb && sale.SubscriptionSetup.SecretsManagerOptions == null)); @@ -84,6 +87,8 @@ public class CloudICloudOrganizationSignUpCommandTests signup.UseSecretsManager = false; signup.IsFromProvider = false; + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(StaticStore.GetPlan(signup.Plan)); + // Extract orgUserId when created Guid? orgUserId = null; await sutProvider.GetDependency() @@ -128,6 +133,7 @@ public class CloudICloudOrganizationSignUpCommandTests signup.IsFromSecretsManagerTrial = false; signup.IsFromProvider = false; + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(StaticStore.GetPlan(signup.Plan)); var result = await sutProvider.Sut.SignUpOrganizationAsync(signup); @@ -157,7 +163,7 @@ public class CloudICloudOrganizationSignUpCommandTests sale.CustomerSetup.TokenizedPaymentSource.Token == signup.PaymentToken && sale.CustomerSetup.TaxInformation.Country == signup.TaxInfo.BillingAddressCountry && sale.CustomerSetup.TaxInformation.PostalCode == signup.TaxInfo.BillingAddressPostalCode && - sale.SubscriptionSetup.Plan == plan && + sale.SubscriptionSetup.PlanType == plan.Type && sale.SubscriptionSetup.PasswordManagerOptions.Seats == signup.AdditionalSeats && sale.SubscriptionSetup.PasswordManagerOptions.Storage == signup.AdditionalStorageGb && sale.SubscriptionSetup.SecretsManagerOptions.Seats == signup.AdditionalSmSeats && @@ -177,6 +183,8 @@ public class CloudICloudOrganizationSignUpCommandTests signup.PremiumAccessAddon = false; signup.IsFromProvider = true; + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(StaticStore.GetPlan(signup.Plan)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SignUpOrganizationAsync(signup)); Assert.Contains("Organizations with a Managed Service Provider do not support Secrets Manager.", exception.Message); } @@ -195,6 +203,8 @@ public class CloudICloudOrganizationSignUpCommandTests signup.AdditionalStorageGb = 0; signup.IsFromProvider = false; + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(StaticStore.GetPlan(signup.Plan)); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SignUpOrganizationAsync(signup)); Assert.Contains("Plan does not allow additional Machine Accounts.", exception.Message); @@ -213,6 +223,8 @@ public class CloudICloudOrganizationSignUpCommandTests signup.AdditionalServiceAccounts = 10; signup.IsFromProvider = false; + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(StaticStore.GetPlan(signup.Plan)); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SignUpOrganizationAsync(signup)); Assert.Contains("You cannot have more Secrets Manager seats than Password Manager seats", exception.Message); @@ -231,6 +243,8 @@ public class CloudICloudOrganizationSignUpCommandTests signup.AdditionalServiceAccounts = -10; signup.IsFromProvider = false; + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(StaticStore.GetPlan(signup.Plan)); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SignUpOrganizationAsync(signup)); Assert.Contains("You can't subtract Machine Accounts!", exception.Message); @@ -249,6 +263,8 @@ public class CloudICloudOrganizationSignUpCommandTests Owner = new User { Id = Guid.NewGuid() } }; + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(StaticStore.GetPlan(signup.Plan)); + sutProvider.GetDependency() .GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id) .Returns(1); diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 52dd39e182..4c42fdfeb9 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -203,10 +204,12 @@ public class OrganizationServiceTests { signup.Plan = PlanType.TeamsMonthly; - var (organization, _, _) = await sutProvider.Sut.SignupClientAsync(signup); - var plan = StaticStore.GetPlan(signup.Plan); + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(plan); + + var (organization, _, _) = await sutProvider.Sut.SignupClientAsync(signup); + await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(org => org.Id == organization.Id && org.Name == signup.Name && @@ -894,6 +897,8 @@ OrganizationUserInvite invite, SutProvider sutProvider) SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); + await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, systemUser: null, invites); await sutProvider.GetDependency().Received(1) @@ -933,6 +938,9 @@ OrganizationUserInvite invite, SutProvider sutProvider) sutProvider.GetDependency().RaiseEventAsync(default) .ThrowsForAnyArgs(); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, systemUser: null, invites)); @@ -1338,6 +1346,9 @@ OrganizationUserInvite invite, SutProvider sutProvider) organization.MaxAutoscaleSeats = currentMaxAutoscaleSeats; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscription(organization.Id, seatAdjustment, maxAutoscaleSeats)); @@ -1360,6 +1371,9 @@ OrganizationUserInvite invite, SutProvider sutProvider) organization.Seats = 100; organization.SmSeats = 100; + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var actual = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscription(organization.Id, seatAdjustment, null)); diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 2e1782f5c8..1f15c5f7fd 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -1,8 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -15,43 +17,6 @@ namespace Bit.Core.Test.Billing.Services; public class OrganizationBillingServiceTests { #region GetMetadata - [Theory, BitAutoData] - public async Task GetMetadata_OrganizationNull_ReturnsNull( - Guid organizationId, - SutProvider sutProvider) - { - var metadata = await sutProvider.Sut.GetMetadata(organizationId); - - Assert.Null(metadata); - } - - [Theory, BitAutoData] - public async Task GetMetadata_CustomerNull_ReturnsNull( - Guid organizationId, - Organization organization, - SutProvider sutProvider) - { - sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); - - var metadata = await sutProvider.Sut.GetMetadata(organizationId); - - Assert.False(metadata.IsOnSecretsManagerStandalone); - } - - [Theory, BitAutoData] - public async Task GetMetadata_SubscriptionNull_ReturnsNull( - Guid organizationId, - Organization organization, - SutProvider sutProvider) - { - sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); - - sutProvider.GetDependency().GetCustomer(organization).Returns(new Customer()); - - var metadata = await sutProvider.Sut.GetMetadata(organizationId); - - Assert.False(metadata.IsOnSecretsManagerStandalone); - } [Theory, BitAutoData] public async Task GetMetadata_Succeeds( @@ -61,6 +26,11 @@ public class OrganizationBillingServiceTests { sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + sutProvider.GetDependency().ListPlans().Returns(StaticStore.Plans.ToList()); + + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + var subscriberService = sutProvider.GetDependency(); subscriberService @@ -99,7 +69,8 @@ public class OrganizationBillingServiceTests var metadata = await sutProvider.Sut.GetMetadata(organizationId); - Assert.True(metadata.IsOnSecretsManagerStandalone); + Assert.True(metadata!.IsOnSecretsManagerStandalone); } + #endregion } diff --git a/test/Core.Test/Models/Business/CompleteSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/CompleteSubscriptionUpdateTests.cs index ceb4735684..dee805033a 100644 --- a/test/Core.Test/Models/Business/CompleteSubscriptionUpdateTests.cs +++ b/test/Core.Test/Models/Business/CompleteSubscriptionUpdateTests.cs @@ -43,7 +43,7 @@ public class CompleteSubscriptionUpdateTests PurchasedPasswordManagerSeats = 20 }; - var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, teamsStarterPlan, updatedSubscriptionData); var upgradeItemOptions = subscriptionUpdate.UpgradeItemsOptions(subscription); @@ -114,7 +114,7 @@ public class CompleteSubscriptionUpdateTests PurchasedAdditionalStorage = 10 }; - var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, teamsMonthlyPlan, updatedSubscriptionData); var upgradeItemOptions = subscriptionUpdate.UpgradeItemsOptions(subscription); @@ -221,7 +221,7 @@ public class CompleteSubscriptionUpdateTests PurchasedAdditionalStorage = 10 }; - var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, teamsMonthlyPlan, updatedSubscriptionData); var upgradeItemOptions = subscriptionUpdate.UpgradeItemsOptions(subscription); @@ -302,7 +302,7 @@ public class CompleteSubscriptionUpdateTests PurchasedPasswordManagerSeats = 20 }; - var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, teamsStarterPlan, updatedSubscriptionData); var revertItemOptions = subscriptionUpdate.RevertItemsOptions(subscription); @@ -372,7 +372,7 @@ public class CompleteSubscriptionUpdateTests PurchasedAdditionalStorage = 10 }; - var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, teamsMonthlyPlan, updatedSubscriptionData); var revertItemOptions = subscriptionUpdate.RevertItemsOptions(subscription); @@ -478,7 +478,7 @@ public class CompleteSubscriptionUpdateTests PurchasedAdditionalStorage = 10 }; - var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, teamsMonthlyPlan, updatedSubscriptionData); var revertItemOptions = subscriptionUpdate.RevertItemsOptions(subscription); diff --git a/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs index faf20eb6dc..6a411363a0 100644 --- a/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs +++ b/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs @@ -2,7 +2,9 @@ using Bit.Core.Billing.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; @@ -11,19 +13,40 @@ namespace Bit.Core.Test.Models.Business; [SecretsManagerOrganizationCustomize] public class SecretsManagerSubscriptionUpdateTests { + private static TheoryData ToPlanTheory(List types) + { + var theoryData = new TheoryData(); + var plans = types.Select(StaticStore.GetPlan).ToArray(); + theoryData.AddRange(plans); + return theoryData; + } + + public static TheoryData NonSmPlans => + ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019]); + + public static TheoryData SmPlans => ToPlanTheory([ + PlanType.EnterpriseAnnually2019, + PlanType.EnterpriseAnnually, + PlanType.TeamsMonthly2019, + PlanType.TeamsAnnually2020, + PlanType.TeamsMonthly, + PlanType.TeamsAnnually2019, + PlanType.TeamsAnnually2020, + PlanType.TeamsAnnually, + PlanType.TeamsStarter + ]); + [Theory] - [BitAutoData(PlanType.Custom)] - [BitAutoData(PlanType.FamiliesAnnually)] - [BitAutoData(PlanType.FamiliesAnnually2019)] + [BitMemberAutoData(nameof(NonSmPlans))] public Task UpdateSubscriptionAsync_WithNonSecretsManagerPlanType_ThrowsBadRequestException( - PlanType planType, + Plan plan, Organization organization) { // Arrange - organization.PlanType = planType; + organization.PlanType = plan.Type; // Act - var exception = Assert.Throws(() => new SecretsManagerSubscriptionUpdate(organization, false)); + var exception = Assert.Throws(() => new SecretsManagerSubscriptionUpdate(organization, plan, false)); // Assert Assert.Contains("Invalid Secrets Manager plan", exception.Message, StringComparison.InvariantCultureIgnoreCase); @@ -31,28 +54,16 @@ public class SecretsManagerSubscriptionUpdateTests } [Theory] - [BitAutoData(PlanType.EnterpriseMonthly2019)] - [BitAutoData(PlanType.EnterpriseMonthly2020)] - [BitAutoData(PlanType.EnterpriseMonthly)] - [BitAutoData(PlanType.EnterpriseAnnually2019)] - [BitAutoData(PlanType.EnterpriseAnnually2020)] - [BitAutoData(PlanType.EnterpriseAnnually)] - [BitAutoData(PlanType.TeamsMonthly2019)] - [BitAutoData(PlanType.TeamsMonthly2020)] - [BitAutoData(PlanType.TeamsMonthly)] - [BitAutoData(PlanType.TeamsAnnually2019)] - [BitAutoData(PlanType.TeamsAnnually2020)] - [BitAutoData(PlanType.TeamsAnnually)] - [BitAutoData(PlanType.TeamsStarter)] + [BitMemberAutoData(nameof(SmPlans))] public void UpdateSubscription_WithNonSecretsManagerPlanType_DoesNotThrowException( - PlanType planType, + Plan plan, Organization organization) { // Arrange - organization.PlanType = planType; + organization.PlanType = plan.Type; // Act - var ex = Record.Exception(() => new SecretsManagerSubscriptionUpdate(organization, false)); + var ex = Record.Exception(() => new SecretsManagerSubscriptionUpdate(organization, plan, false)); // Assert Assert.Null(ex); diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs index 8dcfb198b6..02ae40798b 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; @@ -41,7 +42,8 @@ public class AddSecretsManagerSubscriptionCommandTests { organization.PlanType = planType; - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); + var plan = StaticStore.GetPlan(organization.PlanType); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(plan); await sutProvider.Sut.SignUpAsync(organization, additionalSmSeats, additionalServiceAccounts); @@ -85,6 +87,8 @@ public class AddSecretsManagerSubscriptionCommandTests { organization.GatewayCustomerId = null; organization.PlanType = PlanType.EnterpriseAnnually; + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SignUpAsync(organization, additionalSmSeats, additionalServiceAccounts)); Assert.Contains("No payment method found.", exception.Message); @@ -101,6 +105,8 @@ public class AddSecretsManagerSubscriptionCommandTests { organization.GatewaySubscriptionId = null; organization.PlanType = PlanType.EnterpriseAnnually; + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SignUpAsync(organization, additionalSmSeats, additionalServiceAccounts)); Assert.Contains("No subscription found.", exception.Message); @@ -132,6 +138,8 @@ public class AddSecretsManagerSubscriptionCommandTests organization.UseSecretsManager = false; provider.Type = ProviderType.Msp; sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(provider); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SignUpAsync(organization, 10, 10)); diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index 546ea7770c..50f51da7d0 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -21,26 +21,48 @@ namespace Bit.Core.Test.OrganizationFeatures.OrganizationSubscriptionUpdate; [SecretsManagerOrganizationCustomize] public class UpdateSecretsManagerSubscriptionCommandTests { + private static TheoryData ToPlanTheory(List types) + { + var theoryData = new TheoryData(); + var plans = types.Select(StaticStore.GetPlan).ToArray(); + theoryData.AddRange(plans); + return theoryData; + } + + public static TheoryData AllTeamsAndEnterprise + => ToPlanTheory([ + PlanType.EnterpriseAnnually2019, + PlanType.EnterpriseAnnually2020, + PlanType.EnterpriseAnnually, + PlanType.EnterpriseMonthly2019, + PlanType.EnterpriseMonthly2020, + PlanType.EnterpriseMonthly, + PlanType.TeamsMonthly2019, + PlanType.TeamsMonthly2020, + PlanType.TeamsMonthly, + PlanType.TeamsAnnually2019, + PlanType.TeamsAnnually2020, + PlanType.TeamsAnnually, + PlanType.TeamsStarter + ]); + + public static TheoryData CurrentTeamsAndEnterprise + => ToPlanTheory([ + PlanType.EnterpriseAnnually, + PlanType.EnterpriseMonthly, + PlanType.TeamsMonthly, + PlanType.TeamsAnnually, + PlanType.TeamsStarter + ]); + [Theory] - [BitAutoData(PlanType.EnterpriseAnnually2019)] - [BitAutoData(PlanType.EnterpriseAnnually2020)] - [BitAutoData(PlanType.EnterpriseAnnually)] - [BitAutoData(PlanType.EnterpriseMonthly2019)] - [BitAutoData(PlanType.EnterpriseMonthly2020)] - [BitAutoData(PlanType.EnterpriseMonthly)] - [BitAutoData(PlanType.TeamsMonthly2019)] - [BitAutoData(PlanType.TeamsMonthly2020)] - [BitAutoData(PlanType.TeamsMonthly)] - [BitAutoData(PlanType.TeamsAnnually2019)] - [BitAutoData(PlanType.TeamsAnnually2020)] - [BitAutoData(PlanType.TeamsAnnually)] - [BitAutoData(PlanType.TeamsStarter)] + [BitMemberAutoData(nameof(AllTeamsAndEnterprise))] public async Task UpdateSubscriptionAsync_UpdateEverything_ValidInput_Passes( - PlanType planType, + Plan plan, Organization organization, SutProvider sutProvider) { - organization.PlanType = planType; + organization.PlanType = plan.Type; organization.Seats = 400; organization.SmSeats = 10; organization.MaxAutoscaleSmSeats = 20; @@ -52,7 +74,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var updateMaxAutoscaleSmSeats = 16; var updateMaxAutoscaleSmServiceAccounts = 301; - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmSeats = updateSmSeats, SmServiceAccounts = updateSmServiceAccounts, @@ -62,7 +84,6 @@ public class UpdateSecretsManagerSubscriptionCommandTests await sutProvider.Sut.UpdateSubscriptionAsync(update); - var plan = StaticStore.GetPlan(organization.PlanType); await sutProvider.GetDependency().Received(1) .AdjustSmSeatsAsync(organization, plan, update.SmSeatsExcludingBase); await sutProvider.GetDependency().Received(1) @@ -83,17 +104,13 @@ public class UpdateSecretsManagerSubscriptionCommandTests } [Theory] - [BitAutoData(PlanType.EnterpriseAnnually)] - [BitAutoData(PlanType.EnterpriseMonthly)] - [BitAutoData(PlanType.TeamsMonthly)] - [BitAutoData(PlanType.TeamsAnnually)] - [BitAutoData(PlanType.TeamsStarter)] + [BitMemberAutoData(nameof(CurrentTeamsAndEnterprise))] public async Task UpdateSubscriptionAsync_ValidInput_WithNullMaxAutoscale_Passes( - PlanType planType, + Plan plan, Organization organization, SutProvider sutProvider) { - organization.PlanType = planType; + organization.PlanType = plan.Type; organization.Seats = 20; const int updateSmSeats = 15; @@ -102,7 +119,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests // Ensure that SmSeats is different from the original organization.SmSeats organization.SmSeats = updateSmSeats + 5; - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmSeats = updateSmSeats, MaxAutoscaleSmSeats = null, @@ -112,7 +129,6 @@ public class UpdateSecretsManagerSubscriptionCommandTests await sutProvider.Sut.UpdateSubscriptionAsync(update); - var plan = StaticStore.GetPlan(organization.PlanType); await sutProvider.GetDependency().Received(1) .AdjustSmSeatsAsync(organization, plan, update.SmSeatsExcludingBase); await sutProvider.GetDependency().Received(1) @@ -141,7 +157,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests Organization organization, SutProvider sutProvider) { - var update = new SecretsManagerSubscriptionUpdate(organization, autoscaling).AdjustSeats(2); + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, autoscaling).AdjustSeats(2); sutProvider.GetDependency().SelfHosted.Returns(true); @@ -156,8 +173,10 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider, Organization organization) { + var plan = StaticStore.GetPlan(organization.PlanType); + organization.UseSecretsManager = false; - var update = new SecretsManagerSubscriptionUpdate(organization, false); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateSubscriptionAsync(update)); @@ -167,27 +186,16 @@ public class UpdateSecretsManagerSubscriptionCommandTests } [Theory] - [BitAutoData(PlanType.EnterpriseAnnually2019)] - [BitAutoData(PlanType.EnterpriseAnnually2020)] - [BitAutoData(PlanType.EnterpriseAnnually)] - [BitAutoData(PlanType.EnterpriseMonthly2019)] - [BitAutoData(PlanType.EnterpriseMonthly2020)] - [BitAutoData(PlanType.EnterpriseMonthly)] - [BitAutoData(PlanType.TeamsMonthly2019)] - [BitAutoData(PlanType.TeamsMonthly2020)] - [BitAutoData(PlanType.TeamsMonthly)] - [BitAutoData(PlanType.TeamsAnnually2019)] - [BitAutoData(PlanType.TeamsAnnually2020)] - [BitAutoData(PlanType.TeamsAnnually)] - [BitAutoData(PlanType.TeamsStarter)] + [BitMemberAutoData(nameof(AllTeamsAndEnterprise))] public async Task UpdateSubscriptionAsync_PaidPlan_NullGatewayCustomerId_ThrowsException( - PlanType planType, + Plan plan, Organization organization, SutProvider sutProvider) { - organization.PlanType = planType; + organization.PlanType = plan.Type; organization.GatewayCustomerId = null; - var update = new SecretsManagerSubscriptionUpdate(organization, false).AdjustSeats(1); + + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false).AdjustSeats(1); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("No payment method found.", exception.Message); @@ -195,27 +203,15 @@ public class UpdateSecretsManagerSubscriptionCommandTests } [Theory] - [BitAutoData(PlanType.EnterpriseAnnually2019)] - [BitAutoData(PlanType.EnterpriseAnnually2020)] - [BitAutoData(PlanType.EnterpriseAnnually)] - [BitAutoData(PlanType.EnterpriseMonthly2019)] - [BitAutoData(PlanType.EnterpriseMonthly2020)] - [BitAutoData(PlanType.EnterpriseMonthly)] - [BitAutoData(PlanType.TeamsMonthly2019)] - [BitAutoData(PlanType.TeamsMonthly2020)] - [BitAutoData(PlanType.TeamsMonthly)] - [BitAutoData(PlanType.TeamsAnnually2019)] - [BitAutoData(PlanType.TeamsAnnually2020)] - [BitAutoData(PlanType.TeamsAnnually)] - [BitAutoData(PlanType.TeamsStarter)] + [BitMemberAutoData(nameof(AllTeamsAndEnterprise))] public async Task UpdateSubscriptionAsync_PaidPlan_NullGatewaySubscriptionId_ThrowsException( - PlanType planType, + Plan plan, Organization organization, SutProvider sutProvider) { - organization.PlanType = planType; + organization.PlanType = plan.Type; organization.GatewaySubscriptionId = null; - var update = new SecretsManagerSubscriptionUpdate(organization, false).AdjustSeats(1); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false).AdjustSeats(1); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("No subscription found.", exception.Message); @@ -223,24 +219,12 @@ public class UpdateSecretsManagerSubscriptionCommandTests } [Theory] - [BitAutoData(PlanType.EnterpriseAnnually2019)] - [BitAutoData(PlanType.EnterpriseAnnually2020)] - [BitAutoData(PlanType.EnterpriseAnnually)] - [BitAutoData(PlanType.EnterpriseMonthly2019)] - [BitAutoData(PlanType.EnterpriseMonthly2020)] - [BitAutoData(PlanType.EnterpriseMonthly)] - [BitAutoData(PlanType.TeamsMonthly2019)] - [BitAutoData(PlanType.TeamsMonthly2020)] - [BitAutoData(PlanType.TeamsMonthly)] - [BitAutoData(PlanType.TeamsAnnually2019)] - [BitAutoData(PlanType.TeamsAnnually2020)] - [BitAutoData(PlanType.TeamsAnnually)] - [BitAutoData(PlanType.TeamsStarter)] - public async Task AdjustServiceAccountsAsync_WithEnterpriseOrTeamsPlans_Success(PlanType planType, Guid organizationId, + [BitMemberAutoData(nameof(AllTeamsAndEnterprise))] + public async Task AdjustServiceAccountsAsync_WithEnterpriseOrTeamsPlans_Success( + Plan plan, + Guid organizationId, SutProvider sutProvider) { - var plan = StaticStore.GetPlan(planType); - var organizationSeats = plan.SecretsManager.BaseSeats + 10; var organizationMaxAutoscaleSeats = 20; var organizationServiceAccounts = plan.SecretsManager.BaseServiceAccount + 10; @@ -249,7 +233,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var organization = new Organization { Id = organizationId, - PlanType = planType, + PlanType = plan.Type, GatewayCustomerId = "1", GatewaySubscriptionId = "2", UseSecretsManager = true, @@ -263,7 +247,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var expectedSmServiceAccounts = organizationServiceAccounts + smServiceAccountsAdjustment; var expectedSmServiceAccountsExcludingBase = expectedSmServiceAccounts - plan.SecretsManager.BaseServiceAccount; - var update = new SecretsManagerSubscriptionUpdate(organization, false).AdjustServiceAccounts(10); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false).AdjustServiceAccounts(10); await sutProvider.Sut.UpdateSubscriptionAsync(update); @@ -290,8 +274,9 @@ public class UpdateSecretsManagerSubscriptionCommandTests // Make sure Password Manager seats is greater or equal to Secrets Manager seats organization.Seats = seatCount; + var plan = StaticStore.GetPlan(organization.PlanType); - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmSeats = seatCount, MaxAutoscaleSmSeats = seatCount @@ -310,7 +295,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider) { organization.SmSeats = null; - var update = new SecretsManagerSubscriptionUpdate(organization, false).AdjustSeats(1); + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false).AdjustSeats(1); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateSubscriptionAsync(update)); @@ -325,7 +311,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests Organization organization, SutProvider sutProvider) { - var update = new SecretsManagerSubscriptionUpdate(organization, true).AdjustSeats(-2); + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, true).AdjustSeats(-2); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("Cannot use autoscaling to subtract seats.", exception.Message); @@ -340,7 +327,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider) { organization.PlanType = planType; - var update = new SecretsManagerSubscriptionUpdate(organization, false).AdjustSeats(1); + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false).AdjustSeats(1); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("You have reached the maximum number of Secrets Manager seats (2) for this plan", @@ -357,7 +345,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests organization.SmSeats = 9; organization.MaxAutoscaleSmSeats = 10; - var update = new SecretsManagerSubscriptionUpdate(organization, true).AdjustSeats(2); + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, true).AdjustSeats(2); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("Secrets Manager seat limit has been reached.", exception.Message); @@ -370,7 +359,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests Organization organization, SutProvider sutProvider) { - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmSeats = organization.SmSeats + 10, MaxAutoscaleSmSeats = organization.SmSeats + 5 @@ -388,7 +378,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests Organization organization, SutProvider sutProvider) { - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmSeats = 0, }; @@ -407,7 +398,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider) { organization.SmSeats = 8; - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmSeats = 7, }; @@ -425,7 +417,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests Organization organization, SutProvider sutProvider) { - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmServiceAccounts = 300, MaxAutoscaleSmServiceAccounts = 300 @@ -444,7 +437,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider) { organization.SmServiceAccounts = null; - var update = new SecretsManagerSubscriptionUpdate(organization, false).AdjustServiceAccounts(1); + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false).AdjustServiceAccounts(1); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("Organization has no machine accounts limit, no need to adjust machine accounts", exception.Message); @@ -457,7 +451,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests Organization organization, SutProvider sutProvider) { - var update = new SecretsManagerSubscriptionUpdate(organization, true).AdjustServiceAccounts(-2); + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, true).AdjustServiceAccounts(-2); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("Cannot use autoscaling to subtract machine accounts.", exception.Message); @@ -472,7 +467,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider) { organization.PlanType = planType; - var update = new SecretsManagerSubscriptionUpdate(organization, false).AdjustServiceAccounts(1); + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false).AdjustServiceAccounts(1); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("You have reached the maximum number of machine accounts (3) for this plan", @@ -489,7 +485,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests organization.SmServiceAccounts = 9; organization.MaxAutoscaleSmServiceAccounts = 10; - var update = new SecretsManagerSubscriptionUpdate(organization, true).AdjustServiceAccounts(2); + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, true).AdjustServiceAccounts(2); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("Secrets Manager machine account limit has been reached.", exception.Message); @@ -508,7 +505,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests organization.SmServiceAccounts = smServiceAccount - 5; organization.MaxAutoscaleSmServiceAccounts = 2 * smServiceAccount; - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmServiceAccounts = smServiceAccount, MaxAutoscaleSmServiceAccounts = maxAutoscaleSmServiceAccounts @@ -530,7 +528,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests organization.SmServiceAccounts = newSmServiceAccounts - 10; - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmServiceAccounts = newSmServiceAccounts, }; @@ -542,28 +541,16 @@ public class UpdateSecretsManagerSubscriptionCommandTests } [Theory] - [BitAutoData(PlanType.EnterpriseAnnually2019)] - [BitAutoData(PlanType.EnterpriseAnnually2020)] - [BitAutoData(PlanType.EnterpriseAnnually)] - [BitAutoData(PlanType.EnterpriseMonthly2019)] - [BitAutoData(PlanType.EnterpriseMonthly2020)] - [BitAutoData(PlanType.EnterpriseMonthly)] - [BitAutoData(PlanType.TeamsMonthly2019)] - [BitAutoData(PlanType.TeamsMonthly2020)] - [BitAutoData(PlanType.TeamsMonthly)] - [BitAutoData(PlanType.TeamsAnnually2019)] - [BitAutoData(PlanType.TeamsAnnually2020)] - [BitAutoData(PlanType.TeamsAnnually)] - [BitAutoData(PlanType.TeamsStarter)] + [BitMemberAutoData(nameof(AllTeamsAndEnterprise))] public async Task UpdateSmServiceAccounts_WhenCurrentServiceAccountsIsGreaterThanNew_ThrowsBadRequestException( - PlanType planType, + Plan plan, Organization organization, SutProvider sutProvider) { var currentServiceAccounts = 301; - organization.PlanType = planType; + organization.PlanType = plan.Type; organization.SmServiceAccounts = currentServiceAccounts; - var update = new SecretsManagerSubscriptionUpdate(organization, false) { SmServiceAccounts = 201 }; + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmServiceAccounts = 201 }; sutProvider.GetDependency() .GetServiceAccountCountByOrganizationIdAsync(organization.Id) @@ -586,7 +573,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests organization.SmSeats = smSeats - 1; organization.MaxAutoscaleSmSeats = smSeats * 2; - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { SmSeats = smSeats, MaxAutoscaleSmSeats = maxAutoscaleSmSeats @@ -606,7 +594,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests { organization.PlanType = planType; organization.SmSeats = 2; - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { MaxAutoscaleSmSeats = 3 }; @@ -625,7 +614,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests { organization.PlanType = planType; organization.SmSeats = 2; - var update = new SecretsManagerSubscriptionUpdate(organization, false) + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { MaxAutoscaleSmSeats = 2 }; @@ -645,7 +635,8 @@ public class UpdateSecretsManagerSubscriptionCommandTests organization.PlanType = planType; organization.SmServiceAccounts = 3; - var update = new SecretsManagerSubscriptionUpdate(organization, false) { MaxAutoscaleSmServiceAccounts = 3 }; + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { MaxAutoscaleSmServiceAccounts = 3 }; var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscriptionAsync(update)); Assert.Contains("Your plan does not allow machine accounts autoscaling.", exception.Message); diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index 2965a2f03d..8bcee1e8c6 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; @@ -43,6 +44,7 @@ public class UpgradeOrganizationPlanCommandTests SutProvider sutProvider) { upgrade.Plan = organization.PlanType; + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); @@ -58,6 +60,7 @@ public class UpgradeOrganizationPlanCommandTests upgrade.AdditionalSmSeats = 10; upgrade.AdditionalServiceAccounts = 10; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); Assert.Contains("already on this plan", exception.Message); @@ -69,9 +72,11 @@ public class UpgradeOrganizationPlanCommandTests SutProvider sutProvider) { sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); upgrade.AdditionalSmSeats = 10; upgrade.AdditionalSeats = 10; upgrade.Plan = PlanType.TeamsAnnually; + sutProvider.GetDependency().GetPlanOrThrow(upgrade.Plan).Returns(StaticStore.GetPlan(upgrade.Plan)); await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(organization); } @@ -92,6 +97,8 @@ public class UpgradeOrganizationPlanCommandTests organization.PlanType = PlanType.FamiliesAnnually; + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); + organizationUpgrade.AdditionalSeats = 30; organizationUpgrade.UseSecretsManager = true; organizationUpgrade.AdditionalSmSeats = 20; @@ -99,6 +106,8 @@ public class UpgradeOrganizationPlanCommandTests organizationUpgrade.AdditionalStorageGb = 3; organizationUpgrade.Plan = planType; + sutProvider.GetDependency().GetPlanOrThrow(organizationUpgrade.Plan).Returns(StaticStore.GetPlan(organizationUpgrade.Plan)); + await sutProvider.Sut.UpgradePlanAsync(organization.Id, organizationUpgrade); await sutProvider.GetDependency().Received(1).AdjustSubscription( organization, @@ -120,7 +129,10 @@ public class UpgradeOrganizationPlanCommandTests public async Task UpgradePlan_SM_Passes(PlanType planType, Organization organization, OrganizationUpgrade upgrade, SutProvider sutProvider) { + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); + upgrade.Plan = planType; + sutProvider.GetDependency().GetPlanOrThrow(upgrade.Plan).Returns(StaticStore.GetPlan(upgrade.Plan)); var plan = StaticStore.GetPlan(upgrade.Plan); @@ -155,8 +167,10 @@ public class UpgradeOrganizationPlanCommandTests upgrade.AdditionalSeats = 15; upgrade.AdditionalSmSeats = 1; upgrade.AdditionalServiceAccounts = 0; + sutProvider.GetDependency().GetPlanOrThrow(upgrade.Plan).Returns(StaticStore.GetPlan(upgrade.Plan)); organization.SmSeats = 2; + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency() @@ -181,9 +195,11 @@ public class UpgradeOrganizationPlanCommandTests upgrade.AdditionalSeats = 15; upgrade.AdditionalSmSeats = 1; upgrade.AdditionalServiceAccounts = 0; + sutProvider.GetDependency().GetPlanOrThrow(upgrade.Plan).Returns(StaticStore.GetPlan(upgrade.Plan)); organization.SmSeats = 1; organization.SmServiceAccounts = currentServiceAccounts; + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency()