diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 778cd62c2..9207c64ac 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -1,11 +1,16 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; @@ -20,6 +25,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv private readonly IOrganizationService _organizationService; private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IStripeAdapter _stripeAdapter; + private readonly IScaleSeatsCommand _scaleSeatsCommand; + private readonly IFeatureService _featureService; public RemoveOrganizationFromProviderCommand( IEventService eventService, @@ -28,7 +35,9 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv IOrganizationRepository organizationRepository, IOrganizationService organizationService, IProviderOrganizationRepository providerOrganizationRepository, - IStripeAdapter stripeAdapter) + IStripeAdapter stripeAdapter, + IScaleSeatsCommand scaleSeatsCommand, + IFeatureService featureService) { _eventService = eventService; _logger = logger; @@ -37,6 +46,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv _organizationService = organizationService; _providerOrganizationRepository = providerOrganizationRepository; _stripeAdapter = stripeAdapter; + _scaleSeatsCommand = scaleSeatsCommand; + _featureService = featureService; } public async Task RemoveOrganizationFromProvider( @@ -65,8 +76,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv organization.BillingEmail = organizationOwnerEmails.MinBy(email => email); - await _organizationRepository.ReplaceAsync(organization); - var customerUpdateOptions = new CustomerUpdateOptions { Coupon = string.Empty, @@ -75,13 +84,41 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions); - var subscriptionUpdateOptions = new SubscriptionUpdateOptions - { - CollectionMethod = "send_invoice", - DaysUntilDue = 30 - }; + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); - await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions); + if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable) + { + var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager; + var subscriptionCreateOptions = new SubscriptionCreateOptions + { + Customer = organization.GatewayCustomerId, + CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, + DaysUntilDue = 30, + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, + Metadata = new Dictionary + { + { "organizationId", organization.Id.ToString() } + }, + OffSession = true, + ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, + Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }] + }; + var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + organization.GatewaySubscriptionId = subscription.Id; + await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType, + -(organization.Seats ?? 0)); + } + else + { + var subscriptionUpdateOptions = new SubscriptionUpdateOptions + { + CollectionMethod = "send_invoice", + DaysUntilDue = 30 + }; + await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions); + } + + await _organizationRepository.ReplaceAsync(organization); await _mailService.SendProviderUpdatePaymentMethod( organization.Id, 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 7148bcb17..e175b653d 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -1,7 +1,10 @@ using Bit.Commercial.Core.AdminConsole.Providers; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -81,7 +84,7 @@ public class RemoveOrganizationFromProviderCommandTests } [Theory, BitAutoData] - public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations( + public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff( Provider provider, ProviderOrganization providerOrganization, Organization organization, @@ -97,30 +100,90 @@ public class RemoveOrganizationFromProviderCommandTests includeProvider: false) .Returns(true); - var organizationOwnerEmails = new List { "a@gmail.com", "b@gmail.com" }; + var organizationOwnerEmails = new List { "a@example.com", "b@example.com" }; organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails); + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + }); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await organizationRepository.Received(1).ReplaceAsync(Arg.Is( - org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com")); - - var stripeAdapter = sutProvider.GetDependency(); + org => org.Id == organization.Id && org.BillingEmail == "a@example.com")); await stripeAdapter.Received(1).CustomerUpdateAsync( organization.GatewayCustomerId, Arg.Is( - options => options.Coupon == string.Empty && options.Email == "a@gmail.com")); - - await stripeAdapter.Received(1).SubscriptionUpdateAsync( - organization.GatewaySubscriptionId, Arg.Is( - options => options.CollectionMethod == "send_invoice" && options.DaysUntilDue == 30)); + options => options.Coupon == string.Empty && options.Email == "a@example.com")); await sutProvider.GetDependency().Received(1).SendProviderUpdatePaymentMethod( organization.Id, organization.Name, provider.Name, - Arg.Is>(emails => emails.Contains("a@gmail.com") && emails.Contains("b@gmail.com"))); + Arg.Is>(emails => emails.Contains("a@example.com") && emails.Contains("b@example.com"))); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(providerOrganization); + + await sutProvider.GetDependency().Received(1).LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Removed); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_CreatesSubscriptionAndScalesSeats_FeatureFlagON(Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + providerOrganization.ProviderId = provider.Id; + provider.Status = ProviderStatusType.Billable; + var organizationRepository = sutProvider.GetDependency(); + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + Array.Empty(), + includeProvider: false) + .Returns(true); + + var organizationOwnerEmails = new List { "a@example.com", "b@example.com" }; + + organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + }); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true); + await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); + await stripeAdapter.Received(1).CustomerUpdateAsync( + organization.GatewayCustomerId, Arg.Is( + options => options.Coupon == string.Empty && options.Email == "a@example.com")); + + await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(c => + c.Customer == organization.GatewayCustomerId && + c.CollectionMethod == "send_invoice" && + c.DaysUntilDue == 30 && + c.Items.Count == 1 + )); + + await sutProvider.GetDependency().Received(1) + .ScalePasswordManagerSeats(provider, organization.PlanType, -(int)organization.Seats); + + await organizationRepository.Received(1).ReplaceAsync(Arg.Is( + org => org.Id == organization.Id && org.BillingEmail == "a@example.com" && + org.GatewaySubscriptionId == "S-1")); + + await sutProvider.GetDependency().Received(1).SendProviderUpdatePaymentMethod( + organization.Id, + organization.Name, + provider.Name, + Arg.Is>(emails => + emails.Contains("a@example.com") && emails.Contains("b@example.com"))); await sutProvider.GetDependency().Received(1) .DeleteAsync(providerOrganization);