#nullable enable using System.Diagnostics.CodeAnalysis; 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; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; using OneOf; using Stripe; namespace Bit.Commercial.Core.Billing; [RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] public class BusinessUnitConverter( IDataProtectionProvider dataProtectionProvider, GlobalSettings globalSettings, ILogger logger, IMailService mailService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IPricingClient pricingClient, IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, IUserRepository userRepository) : IBusinessUnitConverter { private readonly IDataProtector _dataProtector = dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector"); public async Task FinalizeConversion( Organization organization, Guid userId, string token, string providerKey, string organizationKey) { var user = await userRepository.GetByIdAsync(userId); var (subscription, provider, providerOrganization, providerUser) = await ValidateFinalizationAsync(organization, user, token); var existingPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); var updatedPlan = await pricingClient.GetPlanOrThrow(existingPlan.IsAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly); // Bring organization under management. organization.Plan = updatedPlan.Name; organization.PlanType = updatedPlan.Type; organization.MaxCollections = updatedPlan.PasswordManager.MaxCollections; organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; organization.UsePolicies = updatedPlan.HasPolicies; organization.UseSso = updatedPlan.HasSso; organization.UseGroups = updatedPlan.HasGroups; organization.UseEvents = updatedPlan.HasEvents; organization.UseDirectory = updatedPlan.HasDirectory; organization.UseTotp = updatedPlan.HasTotp; organization.Use2fa = updatedPlan.Has2fa; organization.UseApi = updatedPlan.HasApi; organization.UseResetPassword = updatedPlan.HasResetPassword; organization.SelfHost = updatedPlan.HasSelfHost; organization.UsersGetPremium = updatedPlan.UsersGetPremium; organization.UseCustomPermissions = updatedPlan.HasCustomPermissions; organization.UseScim = updatedPlan.HasScim; organization.UseKeyConnector = updatedPlan.HasKeyConnector; organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; organization.BillingEmail = provider.BillingEmail!; organization.GatewayCustomerId = null; organization.GatewaySubscriptionId = null; organization.ExpirationDate = null; organization.MaxAutoscaleSeats = null; organization.Status = OrganizationStatusType.Managed; // Enable organization access via key exchange. providerOrganization.Key = organizationKey; // Complete provider setup. provider.Gateway = GatewayType.Stripe; provider.GatewayCustomerId = subscription.CustomerId; provider.GatewaySubscriptionId = subscription.Id; provider.Status = ProviderStatusType.Billable; // Enable provider access via key exchange. providerUser.Key = providerKey; providerUser.Status = ProviderUserStatusType.Confirmed; // Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them. await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { CustomFields = [] } }); var metadata = new Dictionary { [StripeConstants.MetadataKeys.OrganizationId] = string.Empty, [StripeConstants.MetadataKeys.ProviderId] = provider.Id.ToString(), ["convertedFrom"] = organization.Id.ToString() }; var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { CustomFields = [ new CustomerInvoiceSettingsCustomFieldOptions { Name = provider.SubscriberType(), Value = provider.DisplayName()?.Length <= 30 ? provider.DisplayName() : provider.DisplayName()?[..30] } ] }, Metadata = metadata }); // Find the existing password manager price on the subscription. var passwordManagerItem = subscription.Items.First(item => { var priceId = existingPlan.HasNonSeatBasedPasswordManagerPlan() ? existingPlan.PasswordManager.StripePlanId : existingPlan.PasswordManager.StripeSeatPlanId; return item.Price.Id == priceId; }); // Get the new business unit price. var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, updatedPlan.Type); // Replace the existing password manager price with the new business unit price. var updateSubscription = stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions { Items = [ new SubscriptionItemOptions { Id = passwordManagerItem.Id, Deleted = true }, new SubscriptionItemOptions { Price = updatedPriceId, Quantity = organization.Seats } ], Metadata = metadata }); await Task.WhenAll(updateCustomer, updateSubscription); // Complete database updates for provider setup. await Task.WhenAll( organizationRepository.ReplaceAsync(organization), providerOrganizationRepository.ReplaceAsync(providerOrganization), providerRepository.ReplaceAsync(provider), providerUserRepository.ReplaceAsync(providerUser)); return provider.Id; } public async Task>> InitiateConversion( Organization organization, string providerAdminEmail) { var user = await userRepository.GetByEmailAsync(providerAdminEmail); var problems = await ValidateInitiationAsync(organization, user); if (problems is { Count: > 0 }) { return problems; } var provider = await providerRepository.CreateAsync(new Provider { Name = organization.Name, BillingEmail = organization.BillingEmail, Status = ProviderStatusType.Pending, UseEvents = true, Type = ProviderType.BusinessUnit }); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); var managedPlanType = plan.IsAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly; var createProviderOrganization = providerOrganizationRepository.CreateAsync(new ProviderOrganization { ProviderId = provider.Id, OrganizationId = organization.Id }); var createProviderPlan = providerPlanRepository.CreateAsync(new ProviderPlan { ProviderId = provider.Id, PlanType = managedPlanType, SeatMinimum = 0, PurchasedSeats = organization.Seats, AllocatedSeats = organization.Seats }); var createProviderUser = providerUserRepository.CreateAsync(new ProviderUser { ProviderId = provider.Id, UserId = user!.Id, Email = user.Email, Status = ProviderUserStatusType.Invited, Type = ProviderUserType.ProviderAdmin }); await Task.WhenAll(createProviderOrganization, createProviderPlan, createProviderUser); await SendInviteAsync(organization, user.Email); return provider.Id; } public Task ResendConversionInvite( Organization organization, string providerAdminEmail) => IfConversionInProgressAsync(organization, providerAdminEmail, async (_, _, providerUser) => { if (!string.IsNullOrEmpty(providerUser.Email)) { await SendInviteAsync(organization, providerUser.Email); } }); public Task ResetConversion( Organization organization, string providerAdminEmail) => IfConversionInProgressAsync(organization, providerAdminEmail, async (provider, providerOrganization, providerUser) => { var tasks = new List { providerOrganizationRepository.DeleteAsync(providerOrganization), providerUserRepository.DeleteAsync(providerUser) }; var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); if (providerPlans is { Count: > 0 }) { tasks.AddRange(providerPlans.Select(providerPlanRepository.DeleteAsync)); } await Task.WhenAll(tasks); await providerRepository.DeleteAsync(provider); }); #region Utilities private async Task IfConversionInProgressAsync( Organization organization, string providerAdminEmail, Func callback) { var user = await userRepository.GetByEmailAsync(providerAdminEmail); if (user == null) { return; } var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); if (provider is not { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }) { return; } var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id); if (providerUser is { Type: ProviderUserType.ProviderAdmin, Status: ProviderUserStatusType.Invited }) { var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); await callback(provider, providerOrganization!, providerUser); } } private async Task SendInviteAsync( Organization organization, string providerAdminEmail) { var token = _dataProtector.Protect( $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); await mailService.SendBusinessUnitConversionInviteAsync(organization, token, providerAdminEmail); } private async Task<(Subscription, Provider, ProviderOrganization, ProviderUser)> ValidateFinalizationAsync( Organization organization, User? user, string token) { if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise) { Fail("Organization must be on an enterprise plan."); } var subscription = await subscriberService.GetSubscription(organization); if (subscription is not { Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.PastDue }) { Fail("Organization must have a valid subscription."); } if (user == null) { Fail("Provider admin must be a Bitwarden user."); } if (!CoreHelpers.TokenIsValid( "BusinessUnitConversionInvite", _dataProtector, token, user.Email, organization.Id, globalSettings.OrganizationInviteExpirationHours)) { Fail("Email token is invalid."); } var organizationUser = await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id); if (organizationUser is not { Status: OrganizationUserStatusType.Confirmed }) { Fail("Provider admin must be a confirmed member of the organization being converted."); } var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); if (provider is not { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }) { Fail("Linked provider is not a pending business unit."); } var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id); if (providerUser is not { Type: ProviderUserType.ProviderAdmin, Status: ProviderUserStatusType.Invited }) { Fail("Provider admin has not been invited."); } var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); return (subscription, provider, providerOrganization!, providerUser); [DoesNotReturn] void Fail(string scopedError) { logger.LogError("Could not finalize business unit conversion for organization ({OrganizationID}): {Error}", organization.Id, scopedError); throw new BillingException(); } } private async Task?> ValidateInitiationAsync( Organization organization, User? user) { var problems = new List(); if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise) { problems.Add("Organization must be on an enterprise plan."); } var subscription = await subscriberService.GetSubscription(organization); if (subscription is not { Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.PastDue }) { problems.Add("Organization must have a valid subscription."); } var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); if (providerOrganization != null) { problems.Add("Organization is already linked to a provider."); } if (user == null) { problems.Add("Provider admin must be a Bitwarden user."); } else { var organizationUser = await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id); if (organizationUser is not { Status: OrganizationUserStatusType.Confirmed }) { problems.Add("Provider admin must be a confirmed member of the organization being converted."); } } return problems.Count == 0 ? null : problems; } #endregion }