mirror of
https://github.com/bitwarden/server.git
synced 2025-04-13 00:58:13 -05:00
[PM-18770] Convert Organization to Business Unit (#5610)
* [NO LOGIC] Rename MultiOrganizationEnterprise to BusinessUnit * [Core] Add IMailService.SendBusinessUnitConversionInviteAsync * [Core] Add BusinessUnitConverter * [Admin] Add new permission * [Admin] Add BusinessUnitConverterController * [Admin] Add Convert to Business Unit button to Organization edit page * [Api] Add OrganizationBillingController.SetupBusinessUnitAsync action * [Multi] Propagate provider type to sync response * [Multi] Put updates behind feature flag * [Tests] BusinessUnitConverterTests * Run dotnet format * Fixing post-main merge compilation failure
This commit is contained in:
parent
d85807e94f
commit
54e7fac4d9
@ -48,7 +48,7 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
|
||||
}
|
||||
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
|
||||
public async Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
|
||||
{
|
||||
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||
|
||||
|
@ -692,10 +692,10 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
|
||||
}
|
||||
break;
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
case ProviderType.BusinessUnit:
|
||||
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
|
||||
{
|
||||
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
|
||||
throw new BadRequestException($"Business Unit Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
|
||||
}
|
||||
break;
|
||||
case ProviderType.Reseller:
|
||||
|
@ -0,0 +1,462 @@
|
||||
#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<BusinessUnitConverter> 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<Guid> 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<string, string>
|
||||
{
|
||||
[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<OneOf<Guid, List<string>>> 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<Task>
|
||||
{
|
||||
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<Provider, ProviderOrganization, ProviderUser, Task> 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<List<string>?> ValidateInitiationAsync(
|
||||
Organization organization,
|
||||
User? user)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
|
||||
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
|
||||
}
|
@ -791,7 +791,7 @@ public class ProviderBillingService(
|
||||
Provider provider,
|
||||
Organization organization)
|
||||
{
|
||||
if (provider.Type == ProviderType.MultiOrganizationEnterprise)
|
||||
if (provider.Type == ProviderType.BusinessUnit)
|
||||
{
|
||||
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ public static class ProviderPriceAdapter
|
||||
/// <param name="subscription">The provider's subscription.</param>
|
||||
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.BusinessUnit"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||
public static string GetPriceId(
|
||||
Provider provider,
|
||||
@ -78,7 +78,7 @@ public static class ProviderPriceAdapter
|
||||
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
|
||||
ProviderType.BusinessUnit => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
|
||||
? planType switch
|
||||
{
|
||||
PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually,
|
||||
@ -103,7 +103,7 @@ public static class ProviderPriceAdapter
|
||||
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.BusinessUnit"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||
public static string GetActivePriceId(
|
||||
Provider provider,
|
||||
@ -120,7 +120,7 @@ public static class ProviderPriceAdapter
|
||||
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
ProviderType.MultiOrganizationEnterprise => planType switch
|
||||
ProviderType.BusinessUnit => planType switch
|
||||
{
|
||||
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||
|
@ -16,5 +16,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
|
||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
||||
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ public class CreateProviderCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_Success(
|
||||
public async Task CreateBusinessUnitAsync_Success(
|
||||
Provider provider,
|
||||
User user,
|
||||
PlanType plan,
|
||||
@ -71,13 +71,13 @@ public class CreateProviderCommandTests
|
||||
SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.MultiOrganizationEnterprise;
|
||||
provider.Type = ProviderType.BusinessUnit;
|
||||
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats);
|
||||
await sutProvider.Sut.CreateBusinessUnitAsync(provider, user.Email, plan, minimumSeats);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
|
||||
@ -85,7 +85,7 @@ public class CreateProviderCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws(
|
||||
public async Task CreateBusinessUnitAsync_UserIdIsInvalid_Throws(
|
||||
Provider provider,
|
||||
SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
@ -94,7 +94,7 @@ public class CreateProviderCommandTests
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default));
|
||||
() => sutProvider.Sut.CreateBusinessUnitAsync(provider, default, default, default));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Invalid owner.", exception.Message);
|
||||
|
@ -0,0 +1,501 @@
|
||||
#nullable enable
|
||||
using System.Text;
|
||||
using Bit.Commercial.Core.Billing;
|
||||
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.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 Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.Billing;
|
||||
|
||||
public class BusinessUnitConverterTests
|
||||
{
|
||||
private readonly IDataProtectionProvider _dataProtectionProvider = Substitute.For<IDataProtectionProvider>();
|
||||
private readonly GlobalSettings _globalSettings = new();
|
||||
private readonly ILogger<BusinessUnitConverter> _logger = Substitute.For<ILogger<BusinessUnitConverter>>();
|
||||
private readonly IMailService _mailService = Substitute.For<IMailService>();
|
||||
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository = Substitute.For<IProviderOrganizationRepository>();
|
||||
private readonly IProviderPlanRepository _providerPlanRepository = Substitute.For<IProviderPlanRepository>();
|
||||
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
|
||||
private readonly IProviderUserRepository _providerUserRepository = Substitute.For<IProviderUserRepository>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
|
||||
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
|
||||
|
||||
private BusinessUnitConverter BuildConverter() => new(
|
||||
_dataProtectionProvider,
|
||||
_globalSettings,
|
||||
_logger,
|
||||
_mailService,
|
||||
_organizationRepository,
|
||||
_organizationUserRepository,
|
||||
_pricingClient,
|
||||
_providerOrganizationRepository,
|
||||
_providerPlanRepository,
|
||||
_providerRepository,
|
||||
_providerUserRepository,
|
||||
_stripeAdapter,
|
||||
_subscriberService,
|
||||
_userRepository);
|
||||
|
||||
#region FinalizeConversion
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task FinalizeConversion_Succeeds_ReturnsProviderId(
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
string providerKey,
|
||||
string organizationKey)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually2020;
|
||||
|
||||
var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "subscription_id",
|
||||
CustomerId = "customer_id",
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "subscription_item_id",
|
||||
Price = new Price
|
||||
{
|
||||
Id = enterpriseAnnually2020.PasswordManager.StripeSeatPlanId
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(subscription);
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = "provider-admin@example.com"
|
||||
};
|
||||
|
||||
_userRepository.GetByIdAsync(userId).Returns(user);
|
||||
|
||||
var token = SetupDataProtection(organization, user.Email);
|
||||
|
||||
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed };
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
|
||||
.Returns(organizationUser);
|
||||
|
||||
var provider = new Provider
|
||||
{
|
||||
Type = ProviderType.BusinessUnit,
|
||||
Status = ProviderStatusType.Pending
|
||||
};
|
||||
|
||||
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
{
|
||||
Type = ProviderUserType.ProviderAdmin,
|
||||
Status = ProviderUserStatusType.Invited
|
||||
};
|
||||
|
||||
_providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var providerOrganization = new ProviderOrganization();
|
||||
|
||||
_providerOrganizationRepository.GetByOrganizationId(organization.Id).Returns(providerOrganization);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
|
||||
.Returns(enterpriseAnnually2020);
|
||||
|
||||
var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually)
|
||||
.Returns(enterpriseAnnually);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey);
|
||||
|
||||
await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any<CustomerUpdateOptions>());
|
||||
|
||||
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type);
|
||||
|
||||
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is<SubscriptionUpdateOptions>(
|
||||
arguments =>
|
||||
arguments.Items.Count == 2 &&
|
||||
arguments.Items[0].Id == "subscription_item_id" &&
|
||||
arguments.Items[0].Deleted == true &&
|
||||
arguments.Items[1].Price == updatedPriceId &&
|
||||
arguments.Items[1].Quantity == organization.Seats));
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(arguments =>
|
||||
arguments.PlanType == PlanType.EnterpriseAnnually &&
|
||||
arguments.Status == OrganizationStatusType.Managed &&
|
||||
arguments.GatewayCustomerId == null &&
|
||||
arguments.GatewaySubscriptionId == null));
|
||||
|
||||
await _providerOrganizationRepository.Received(1).ReplaceAsync(Arg.Is<ProviderOrganization>(arguments =>
|
||||
arguments.Key == organizationKey));
|
||||
|
||||
await _providerRepository.Received(1).ReplaceAsync(Arg.Is<Provider>(arguments =>
|
||||
arguments.Gateway == GatewayType.Stripe &&
|
||||
arguments.GatewayCustomerId == subscription.CustomerId &&
|
||||
arguments.GatewaySubscriptionId == subscription.Id &&
|
||||
arguments.Status == ProviderStatusType.Billable));
|
||||
|
||||
await _providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(arguments =>
|
||||
arguments.Key == providerKey &&
|
||||
arguments.Status == ProviderUserStatusType.Confirmed));
|
||||
}
|
||||
|
||||
/*
|
||||
* Because the validation for finalization is not an applicative like initialization is,
|
||||
* I'm just testing one specific failure here. I don't see much value in testing every single opportunity for failure.
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task FinalizeConversion_ValidationFails_ThrowsBillingException(
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
string token,
|
||||
string providerKey,
|
||||
string organizationKey)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually2020;
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Canceled
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(subscription);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await Assert.ThrowsAsync<BillingException>(() =>
|
||||
businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey));
|
||||
|
||||
await _organizationUserRepository.DidNotReceiveWithAnyArgs()
|
||||
.GetByOrganizationAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InitiateConversion
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task InitiateConversion_Succeeds_ReturnsProviderId(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Active
|
||||
});
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = providerAdminEmail
|
||||
};
|
||||
|
||||
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
|
||||
|
||||
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed };
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
|
||||
.Returns(organizationUser);
|
||||
|
||||
var provider = new Provider { Id = Guid.NewGuid() };
|
||||
|
||||
_providerRepository.CreateAsync(Arg.Is<Provider>(argument =>
|
||||
argument.Name == organization.Name &&
|
||||
argument.BillingEmail == organization.BillingEmail &&
|
||||
argument.Status == ProviderStatusType.Pending &&
|
||||
argument.Type == ProviderType.BusinessUnit)).Returns(provider);
|
||||
|
||||
var plan = StaticStore.GetPlan(organization.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
|
||||
|
||||
var token = SetupDataProtection(organization, providerAdminEmail);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
var providerId = result.AsT0;
|
||||
|
||||
Assert.Equal(provider.Id, providerId);
|
||||
|
||||
await _providerOrganizationRepository.Received(1).CreateAsync(
|
||||
Arg.Is<ProviderOrganization>(argument =>
|
||||
argument.ProviderId == provider.Id &&
|
||||
argument.OrganizationId == organization.Id));
|
||||
|
||||
await _providerPlanRepository.Received(1).CreateAsync(
|
||||
Arg.Is<ProviderPlan>(argument =>
|
||||
argument.ProviderId == provider.Id &&
|
||||
argument.PlanType == PlanType.EnterpriseAnnually &&
|
||||
argument.SeatMinimum == 0 &&
|
||||
argument.PurchasedSeats == organization.Seats &&
|
||||
argument.AllocatedSeats == organization.Seats));
|
||||
|
||||
await _providerUserRepository.Received(1).CreateAsync(
|
||||
Arg.Is<ProviderUser>(argument =>
|
||||
argument.ProviderId == provider.Id &&
|
||||
argument.UserId == user.Id &&
|
||||
argument.Email == user.Email &&
|
||||
argument.Status == ProviderUserStatusType.Invited &&
|
||||
argument.Type == ProviderUserType.ProviderAdmin));
|
||||
|
||||
await _mailService.Received(1).SendBusinessUnitConversionInviteAsync(
|
||||
organization,
|
||||
token,
|
||||
user.Email);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task InitiateConversion_ValidationFails_ReturnsErrors(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Canceled
|
||||
});
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = providerAdminEmail
|
||||
};
|
||||
|
||||
_providerOrganizationRepository.GetByOrganizationId(organization.Id)
|
||||
.Returns(new ProviderOrganization());
|
||||
|
||||
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
|
||||
|
||||
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Invited };
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
|
||||
.Returns(organizationUser);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail);
|
||||
|
||||
Assert.True(result.IsT1);
|
||||
|
||||
var problems = result.AsT1;
|
||||
|
||||
Assert.Contains("Organization must be on an enterprise plan.", problems);
|
||||
|
||||
Assert.Contains("Organization must have a valid subscription.", problems);
|
||||
|
||||
Assert.Contains("Organization is already linked to a provider.", problems);
|
||||
|
||||
Assert.Contains("Provider admin must be a confirmed member of the organization being converted.", problems);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ResendConversionInvite
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ResendConversionInvite_ConversionInProgress_Succeeds(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
SetupConversionInProgress(organization, providerAdminEmail);
|
||||
|
||||
var token = SetupDataProtection(organization, providerAdminEmail);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail);
|
||||
|
||||
await _mailService.Received(1).SendBusinessUnitConversionInviteAsync(
|
||||
organization,
|
||||
token,
|
||||
providerAdminEmail);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ResendConversionInvite_NoConversionInProgress_DoesNothing(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
SetupDataProtection(organization, providerAdminEmail);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail);
|
||||
|
||||
await _mailService.DidNotReceiveWithAnyArgs().SendBusinessUnitConversionInviteAsync(
|
||||
Arg.Any<Organization>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ResetConversion
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ResetConversion_ConversionInProgress_Succeeds(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var (provider, providerOrganization, providerUser, providerPlan) = SetupConversionInProgress(organization, providerAdminEmail);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.ResetConversion(organization, providerAdminEmail);
|
||||
|
||||
await _providerOrganizationRepository.Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await _providerUserRepository.Received(1)
|
||||
.DeleteAsync(providerUser);
|
||||
|
||||
await _providerPlanRepository.Received(1)
|
||||
.DeleteAsync(providerPlan);
|
||||
|
||||
await _providerRepository.Received(1)
|
||||
.DeleteAsync(provider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ResetConversion_NoConversionInProgress_DoesNothing(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.ResetConversion(organization, providerAdminEmail);
|
||||
|
||||
await _providerOrganizationRepository.DidNotReceiveWithAnyArgs()
|
||||
.DeleteAsync(Arg.Any<ProviderOrganization>());
|
||||
|
||||
await _providerUserRepository.DidNotReceiveWithAnyArgs()
|
||||
.DeleteAsync(Arg.Any<ProviderUser>());
|
||||
|
||||
await _providerPlanRepository.DidNotReceiveWithAnyArgs()
|
||||
.DeleteAsync(Arg.Any<ProviderPlan>());
|
||||
|
||||
await _providerRepository.DidNotReceiveWithAnyArgs()
|
||||
.DeleteAsync(Arg.Any<Provider>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Utilities
|
||||
|
||||
private string SetupDataProtection(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var dataProtector = new MockDataProtector(organization, providerAdminEmail);
|
||||
_dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector").Returns(dataProtector);
|
||||
return dataProtector.Protect(dataProtector.Token);
|
||||
}
|
||||
|
||||
private (Provider, ProviderOrganization, ProviderUser, ProviderPlan) SetupConversionInProgress(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var user = new User { Id = Guid.NewGuid() };
|
||||
|
||||
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
|
||||
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.BusinessUnit,
|
||||
Status = ProviderStatusType.Pending
|
||||
};
|
||||
|
||||
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
UserId = user.Id,
|
||||
Type = ProviderUserType.ProviderAdmin,
|
||||
Status = ProviderUserStatusType.Invited,
|
||||
Email = providerAdminEmail
|
||||
};
|
||||
|
||||
_providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id)
|
||||
.Returns(providerUser);
|
||||
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organization.Id,
|
||||
ProviderId = provider.Id
|
||||
};
|
||||
|
||||
_providerOrganizationRepository.GetByOrganizationId(organization.Id)
|
||||
.Returns(providerOrganization);
|
||||
|
||||
var providerPlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseAnnually
|
||||
};
|
||||
|
||||
_providerPlanRepository.GetByProviderId(provider.Id).Returns([providerPlan]);
|
||||
|
||||
return (provider, providerOrganization, providerUser, providerPlan);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class MockDataProtector(
|
||||
Organization organization,
|
||||
string providerAdminEmail) : IDataProtector
|
||||
{
|
||||
public string Token = $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}";
|
||||
|
||||
public IDataProtector CreateProtector(string purpose) => this;
|
||||
|
||||
public byte[] Protect(byte[] plaintext) => Encoding.UTF8.GetBytes(Token);
|
||||
|
||||
public byte[] Unprotect(byte[] protectedData) => Encoding.UTF8.GetBytes(Token);
|
||||
}
|
@ -116,7 +116,7 @@ public class ProviderBillingServiceTests
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.MultiOrganizationEnterprise;
|
||||
provider.Type = ProviderType.BusinessUnit;
|
||||
|
||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||
var existingPlan = new ProviderPlan
|
||||
|
@ -71,7 +71,7 @@ public class ProviderPriceAdapterTests
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.MultiOrganizationEnterprise
|
||||
Type = ProviderType.BusinessUnit
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
@ -98,7 +98,7 @@ public class ProviderPriceAdapterTests
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.MultiOrganizationEnterprise
|
||||
Type = ProviderType.BusinessUnit
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
@ -141,7 +141,7 @@ public class ProviderPriceAdapterTests
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.MultiOrganizationEnterprise
|
||||
Type = ProviderType.BusinessUnit
|
||||
};
|
||||
|
||||
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
|
||||
|
@ -14,9 +14,6 @@
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Billing\Controllers\" />
|
||||
</ItemGroup>
|
||||
|
||||
<Choose>
|
||||
<When Condition="!$(DefineConstants.Contains('OSS'))">
|
||||
|
@ -133,10 +133,10 @@ public class ProvidersController : Controller
|
||||
return View(new CreateResellerProviderModel());
|
||||
}
|
||||
|
||||
[HttpGet("providers/create/multi-organization-enterprise")]
|
||||
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
|
||||
[HttpGet("providers/create/business-unit")]
|
||||
public IActionResult CreateBusinessUnit(int enterpriseMinimumSeats, string ownerEmail = null)
|
||||
{
|
||||
return View(new CreateMultiOrganizationEnterpriseProviderModel
|
||||
return View(new CreateBusinessUnitProviderModel
|
||||
{
|
||||
OwnerEmail = ownerEmail,
|
||||
EnterpriseSeatMinimum = enterpriseMinimumSeats
|
||||
@ -157,7 +157,7 @@ public class ProvidersController : Controller
|
||||
{
|
||||
ProviderType.Msp => RedirectToAction("CreateMsp"),
|
||||
ProviderType.Reseller => RedirectToAction("CreateReseller"),
|
||||
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
|
||||
ProviderType.BusinessUnit => RedirectToAction("CreateBusinessUnit"),
|
||||
_ => View(model)
|
||||
};
|
||||
}
|
||||
@ -198,10 +198,10 @@ public class ProvidersController : Controller
|
||||
return RedirectToAction("Edit", new { id = provider.Id });
|
||||
}
|
||||
|
||||
[HttpPost("providers/create/multi-organization-enterprise")]
|
||||
[HttpPost("providers/create/business-unit")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Provider_Create)]
|
||||
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
|
||||
public async Task<IActionResult> CreateBusinessUnit(CreateBusinessUnitProviderModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
@ -209,7 +209,7 @@ public class ProvidersController : Controller
|
||||
}
|
||||
var provider = model.ToProvider();
|
||||
|
||||
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
|
||||
await _createProviderCommand.CreateBusinessUnitAsync(
|
||||
provider,
|
||||
model.OwnerEmail,
|
||||
model.Plan.Value,
|
||||
@ -307,7 +307,7 @@ public class ProvidersController : Controller
|
||||
]);
|
||||
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
|
||||
break;
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
case ProviderType.BusinessUnit:
|
||||
{
|
||||
var existingMoePlan = providerPlans.Single();
|
||||
|
||||
|
@ -6,7 +6,7 @@ using Bit.SharedWeb.Utilities;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
||||
public class CreateBusinessUnitProviderModel : IValidatableObject
|
||||
{
|
||||
[Display(Name = "Owner Email")]
|
||||
public string OwnerEmail { get; set; }
|
||||
@ -22,7 +22,7 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
||||
{
|
||||
return new Provider
|
||||
{
|
||||
Type = ProviderType.MultiOrganizationEnterprise
|
||||
Type = ProviderType.BusinessUnit
|
||||
};
|
||||
}
|
||||
|
||||
@ -30,17 +30,17 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
||||
{
|
||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
||||
}
|
||||
if (EnterpriseSeatMinimum < 0)
|
||||
{
|
||||
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
|
||||
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
|
||||
yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative.");
|
||||
}
|
||||
if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly)
|
||||
{
|
||||
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(Plan);
|
||||
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(Plan);
|
||||
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
|
||||
}
|
||||
}
|
@ -34,7 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
||||
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
||||
Type = provider.Type;
|
||||
|
||||
if (Type == ProviderType.MultiOrganizationEnterprise)
|
||||
if (Type == ProviderType.BusinessUnit)
|
||||
{
|
||||
var plan = providerPlans.SingleOrDefault();
|
||||
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
|
||||
@ -100,7 +100,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
||||
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
||||
}
|
||||
break;
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
case ProviderType.BusinessUnit:
|
||||
if (Plan == null)
|
||||
{
|
||||
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);
|
||||
|
@ -40,7 +40,7 @@ public class ProviderViewModel
|
||||
ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats));
|
||||
}
|
||||
}
|
||||
else if (Provider.Type == ProviderType.MultiOrganizationEnterprise)
|
||||
else if (Provider.Type == ProviderType.BusinessUnit)
|
||||
{
|
||||
var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly)
|
||||
.Sum(po => po.OccupiedSeats).GetValueOrDefault(0);
|
||||
|
@ -1,8 +1,13 @@
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Admin.Models
|
||||
@using Bit.Core
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Bit.Core.Enums
|
||||
@using Bit.Core.Billing.Extensions
|
||||
@using Bit.Core.Services
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
||||
@inject IFeatureService FeatureService
|
||||
@model OrganizationEditModel
|
||||
@{
|
||||
ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name;
|
||||
@ -13,6 +18,13 @@
|
||||
var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete);
|
||||
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
|
||||
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
|
||||
|
||||
var canConvertToBusinessUnit =
|
||||
FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) &&
|
||||
AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) &&
|
||||
Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise &&
|
||||
!string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) &&
|
||||
Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending };
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
@ -114,6 +126,15 @@
|
||||
Enterprise Trial
|
||||
</button>
|
||||
}
|
||||
@if (canConvertToBusinessUnit)
|
||||
{
|
||||
<a asp-controller="BusinessUnitConversion"
|
||||
asp-action="Index"
|
||||
asp-route-organizationId="@Model.Organization.Id"
|
||||
class="btn btn-secondary me-2">
|
||||
Convert to Business Unit
|
||||
</a>
|
||||
}
|
||||
@if (canUnlinkFromProvider && Model.Provider is not null)
|
||||
{
|
||||
<button class="btn btn-outline-danger me-2"
|
||||
|
@ -1,15 +1,15 @@
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
|
||||
@model CreateMultiOrganizationEnterpriseProviderModel
|
||||
@model CreateBusinessUnitProviderModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
|
||||
ViewData["Title"] = "Create Business Unit Provider";
|
||||
}
|
||||
|
||||
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
|
||||
<h1 class="mb-4">Create Business Unit Provider</h1>
|
||||
<div>
|
||||
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
|
||||
<form method="post" asp-action="CreateBusinessUnit">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="OwnerEmail" class="form-label"></label>
|
||||
@ -19,14 +19,14 @@
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
@{
|
||||
var multiOrgPlans = new List<PlanType>
|
||||
var businessUnitPlanTypes = new List<PlanType>
|
||||
{
|
||||
PlanType.EnterpriseAnnually,
|
||||
PlanType.EnterpriseMonthly
|
||||
};
|
||||
}
|
||||
<label asp-for="Plan" class="form-label"></label>
|
||||
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
@ -74,20 +74,20 @@
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
case ProviderType.BusinessUnit:
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
@{
|
||||
var multiOrgPlans = new List<PlanType>
|
||||
var businessUnitPlanTypes = new List<PlanType>
|
||||
{
|
||||
PlanType.EnterpriseAnnually,
|
||||
PlanType.EnterpriseMonthly
|
||||
};
|
||||
}
|
||||
<label asp-for="Plan" class="form-label"></label>
|
||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
|
@ -0,0 +1,185 @@
|
||||
#nullable enable
|
||||
using Bit.Admin.Billing.Models;
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Utilities;
|
||||
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.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Admin.Billing.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[Route("organizations/billing/{organizationId:guid}/business-unit")]
|
||||
[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)]
|
||||
public class BusinessUnitConversionController(
|
||||
IBusinessUnitConverter businessUnitConverter,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> IndexAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var model = new BusinessUnitConversionModel { Organization = organization };
|
||||
|
||||
var invitedProviderAdmin = await GetInvitedProviderAdminAsync(organization);
|
||||
|
||||
if (invitedProviderAdmin != null)
|
||||
{
|
||||
model.ProviderAdminEmail = invitedProviderAdmin.Email;
|
||||
model.ProviderId = invitedProviderAdmin.ProviderId;
|
||||
}
|
||||
|
||||
var success = ReadSuccessMessage();
|
||||
|
||||
if (!string.IsNullOrEmpty(success))
|
||||
{
|
||||
model.Success = success;
|
||||
}
|
||||
|
||||
var errors = ReadErrorMessages();
|
||||
|
||||
if (errors is { Count: > 0 })
|
||||
{
|
||||
model.Errors = errors;
|
||||
}
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> InitiateAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
BusinessUnitConversionModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var result = await businessUnitConverter.InitiateConversion(
|
||||
organization,
|
||||
model.ProviderAdminEmail!);
|
||||
|
||||
return result.Match(
|
||||
providerId => RedirectToAction("Edit", "Providers", new { id = providerId }),
|
||||
errors =>
|
||||
{
|
||||
PersistErrorMessages(errors);
|
||||
return RedirectToAction("Index", new { organizationId });
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("reset")]
|
||||
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> ResetAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
BusinessUnitConversionModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await businessUnitConverter.ResetConversion(organization, model.ProviderAdminEmail!);
|
||||
|
||||
PersistSuccessMessage("Business unit conversion was successfully reset.");
|
||||
|
||||
return RedirectToAction("Index", new { organizationId });
|
||||
}
|
||||
|
||||
[HttpPost("resend-invite")]
|
||||
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> ResendInviteAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
BusinessUnitConversionModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await businessUnitConverter.ResendConversionInvite(organization, model.ProviderAdminEmail!);
|
||||
|
||||
PersistSuccessMessage($"Invite was successfully resent to {model.ProviderAdminEmail}.");
|
||||
|
||||
return RedirectToAction("Index", new { organizationId });
|
||||
}
|
||||
|
||||
private async Task<ProviderUser?> GetInvitedProviderAdminAsync(
|
||||
Organization organization)
|
||||
{
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
if (provider is not
|
||||
{
|
||||
Type: ProviderType.BusinessUnit,
|
||||
Status: ProviderStatusType.Pending
|
||||
})
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var providerUsers =
|
||||
await providerUserRepository.GetManyByProviderAsync(provider.Id, ProviderUserType.ProviderAdmin);
|
||||
|
||||
if (providerUsers.Count != 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var providerUser = providerUsers.First();
|
||||
|
||||
return providerUser is
|
||||
{
|
||||
Type: ProviderUserType.ProviderAdmin,
|
||||
Status: ProviderUserStatusType.Invited,
|
||||
UserId: not null
|
||||
} ? providerUser : null;
|
||||
}
|
||||
|
||||
private const string _errors = "errors";
|
||||
private const string _success = "Success";
|
||||
|
||||
private void PersistSuccessMessage(string message) => TempData[_success] = message;
|
||||
private void PersistErrorMessages(List<string> errors)
|
||||
{
|
||||
var input = string.Join("|", errors);
|
||||
TempData[_errors] = input;
|
||||
}
|
||||
private string? ReadSuccessMessage() => ReadTempData<string>(_success);
|
||||
private List<string>? ReadErrorMessages()
|
||||
{
|
||||
var output = ReadTempData<string>(_errors);
|
||||
return string.IsNullOrEmpty(output) ? null : output.Split('|').ToList();
|
||||
}
|
||||
|
||||
private T? ReadTempData<T>(string key) => TempData.TryGetValue(key, out var obj) && obj is T value ? value : default;
|
||||
}
|
25
src/Admin/Billing/Models/BusinessUnitConversionModel.cs
Normal file
25
src/Admin/Billing/Models/BusinessUnitConversionModel.cs
Normal file
@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Bit.Admin.Billing.Models;
|
||||
|
||||
public class BusinessUnitConversionModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[Display(Name = "Provider Admin Email")]
|
||||
public string? ProviderAdminEmail { get; set; }
|
||||
|
||||
[BindNever]
|
||||
public required Organization Organization { get; set; }
|
||||
|
||||
[BindNever]
|
||||
public Guid? ProviderId { get; set; }
|
||||
|
||||
[BindNever]
|
||||
public string? Success { get; set; }
|
||||
|
||||
[BindNever] public List<string>? Errors { get; set; } = [];
|
||||
}
|
75
src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml
Normal file
75
src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml
Normal file
@ -0,0 +1,75 @@
|
||||
@model Bit.Admin.Billing.Models.BusinessUnitConversionModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Convert Organization to Business Unit";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ProviderAdminEmail))
|
||||
{
|
||||
<h1>Convert @Model.Organization.Name to Business Unit</h1>
|
||||
@if (!string.IsNullOrEmpty(Model.Success))
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show mb-3" role="alert">
|
||||
@Model.Success
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
@if (Model.Errors?.Any() ?? false)
|
||||
{
|
||||
@foreach (var error in Model.Errors)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
|
||||
@error
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<p>This organization has a business unit conversion in progress.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="ProviderAdminEmail" class="form-label"></label>
|
||||
<input type="email" class="form-control" asp-for="ProviderAdminEmail" disabled></input>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<form method="post" asp-controller="BusinessUnitConversion" asp-action="ResendInvite" asp-route-organizationId="@Model.Organization.Id">
|
||||
<input type="hidden" asp-for="ProviderAdminEmail" />
|
||||
<button type="submit" class="btn btn-primary mb-2">Resend Invite</button>
|
||||
</form>
|
||||
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Reset" asp-route-organizationId="@Model.Organization.Id">
|
||||
<input type="hidden" asp-for="ProviderAdminEmail" />
|
||||
<button type="submit" class="btn btn-danger mb-2">Reset Conversion</button>
|
||||
</form>
|
||||
@if (Model.ProviderId.HasValue)
|
||||
{
|
||||
<a asp-controller="Providers"
|
||||
asp-action="Edit"
|
||||
asp-route-id="@Model.ProviderId"
|
||||
class="btn btn-secondary mb-2">
|
||||
Go to Provider
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Convert @Model.Organization.Name to Business Unit</h1>
|
||||
@if (Model.Errors?.Any() ?? false)
|
||||
{
|
||||
@foreach (var error in Model.Errors)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
|
||||
@error
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Initiate" asp-route-organizationId="@Model.Organization.Id">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="ProviderAdminEmail" class="form-label"></label>
|
||||
<input type="email" class="form-control" asp-for="ProviderAdminEmail" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-2">Convert</button>
|
||||
</form>
|
||||
}
|
@ -38,6 +38,7 @@ public enum Permission
|
||||
Org_Billing_View,
|
||||
Org_Billing_Edit,
|
||||
Org_Billing_LaunchGateway,
|
||||
Org_Billing_ConvertToBusinessUnit,
|
||||
|
||||
Provider_List_View,
|
||||
Provider_Create,
|
||||
|
@ -42,6 +42,7 @@ public static class RolePermissionMapping
|
||||
Permission.Org_Billing_View,
|
||||
Permission.Org_Billing_Edit,
|
||||
Permission.Org_Billing_LaunchGateway,
|
||||
Permission.Org_Billing_ConvertToBusinessUnit,
|
||||
Permission.Provider_List_View,
|
||||
Permission.Provider_Create,
|
||||
Permission.Provider_View,
|
||||
@ -90,6 +91,7 @@ public static class RolePermissionMapping
|
||||
Permission.Org_Billing_View,
|
||||
Permission.Org_Billing_Edit,
|
||||
Permission.Org_Billing_LaunchGateway,
|
||||
Permission.Org_Billing_ConvertToBusinessUnit,
|
||||
Permission.Org_InitiateTrial,
|
||||
Permission.Provider_List_View,
|
||||
Permission.Provider_Create,
|
||||
@ -166,6 +168,7 @@ public static class RolePermissionMapping
|
||||
Permission.Org_Billing_View,
|
||||
Permission.Org_Billing_Edit,
|
||||
Permission.Org_Billing_LaunchGateway,
|
||||
Permission.Org_Billing_ConvertToBusinessUnit,
|
||||
Permission.Org_RequestDelete,
|
||||
Permission.Provider_Edit,
|
||||
Permission.Provider_View,
|
||||
|
@ -22,6 +22,7 @@ public class ProfileProviderResponseModel : ResponseModel
|
||||
UserId = provider.UserId;
|
||||
UseEvents = provider.UseEvents;
|
||||
ProviderStatus = provider.ProviderStatus;
|
||||
ProviderType = provider.ProviderType;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@ -35,4 +36,5 @@ public class ProfileProviderResponseModel : ResponseModel
|
||||
public Guid? UserId { get; set; }
|
||||
public bool UseEvents { get; set; }
|
||||
public ProviderStatusType ProviderStatus { get; set; }
|
||||
public ProviderType ProviderType { get; set; }
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
@ -18,7 +19,9 @@ namespace Bit.Api.Billing.Controllers;
|
||||
[Route("organizations/{organizationId:guid}/billing")]
|
||||
[Authorize("Application")]
|
||||
public class OrganizationBillingController(
|
||||
IBusinessUnitConverter businessUnitConverter,
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
IOrganizationBillingService organizationBillingService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPaymentService paymentService,
|
||||
@ -296,4 +299,40 @@ public class OrganizationBillingController(
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("setup-business-unit")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IResult> SetupBusinessUnitAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
[FromBody] SetupBusinessUnitRequestBody requestBody)
|
||||
{
|
||||
var enableOrganizationBusinessUnitConversion =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion);
|
||||
|
||||
if (!enableOrganizationBusinessUnitConversion)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
if (!await currentContext.OrganizationUser(organizationId))
|
||||
{
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
var providerId = await businessUnitConverter.FinalizeConversion(
|
||||
organization,
|
||||
requestBody.UserId,
|
||||
requestBody.Token,
|
||||
requestBody.ProviderKey,
|
||||
requestBody.OrganizationKey);
|
||||
|
||||
return TypedResults.Ok(providerId);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
public class SetupBusinessUnitRequestBody
|
||||
{
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Token { get; set; }
|
||||
|
||||
[Required]
|
||||
public string ProviderKey { get; set; }
|
||||
|
||||
[Required]
|
||||
public string OrganizationKey { get; set; }
|
||||
}
|
@ -8,6 +8,6 @@ public enum ProviderType : byte
|
||||
Msp = 0,
|
||||
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Creates Bitwarden Portal page for client organization billing management", Order = 1000)]
|
||||
Reseller = 1,
|
||||
[Display(ShortName = "MOE", Name = "Multi-organization Enterprises", Description = "Creates provider portal for multi-organization management", Order = 1)]
|
||||
MultiOrganizationEnterprise = 2,
|
||||
[Display(ShortName = "Business Unit", Name = "Business Unit", Description = "Creates provider portal for business unit management", Order = 1)]
|
||||
BusinessUnit = 2,
|
||||
}
|
||||
|
@ -17,4 +17,5 @@ public class ProviderUserProviderDetails
|
||||
public string Permissions { get; set; }
|
||||
public bool UseEvents { get; set; }
|
||||
public ProviderStatusType ProviderStatus { get; set; }
|
||||
public ProviderType ProviderType { get; set; }
|
||||
}
|
||||
|
@ -7,5 +7,5 @@ public interface ICreateProviderCommand
|
||||
{
|
||||
Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats);
|
||||
Task CreateResellerAsync(Provider provider);
|
||||
Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats);
|
||||
Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats);
|
||||
}
|
||||
|
@ -25,19 +25,19 @@ public static class BillingExtensions
|
||||
public static bool IsBillable(this Provider provider) =>
|
||||
provider is
|
||||
{
|
||||
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
|
||||
Type: ProviderType.Msp or ProviderType.BusinessUnit,
|
||||
Status: ProviderStatusType.Billable
|
||||
};
|
||||
|
||||
public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) =>
|
||||
inviteOrganizationProvider is
|
||||
{
|
||||
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
|
||||
Type: ProviderType.Msp or ProviderType.BusinessUnit,
|
||||
Status: ProviderStatusType.Billable
|
||||
};
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
=> providerType is ProviderType.Msp or ProviderType.BusinessUnit;
|
||||
|
||||
public static bool IsValidClient(this Organization organization)
|
||||
=> organization is
|
||||
|
58
src/Core/Billing/Services/IBusinessUnitConverter.cs
Normal file
58
src/Core/Billing/Services/IBusinessUnitConverter.cs
Normal file
@ -0,0 +1,58 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using OneOf;
|
||||
|
||||
namespace Bit.Core.Billing.Services;
|
||||
|
||||
public interface IBusinessUnitConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Finalizes the process of converting the <paramref name="organization"/> to a <see cref="ProviderType.BusinessUnit"/> by
|
||||
/// saving all the necessary key provided by the client and updating the <paramref name="organization"/>'s subscription to a
|
||||
/// provider subscription.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization to convert to a business unit.</param>
|
||||
/// <param name="userId">The ID of the organization member who will be the provider admin.</param>
|
||||
/// <param name="token">The token sent to the client as part of the <see cref="InitiateConversion"/> process.</param>
|
||||
/// <param name="providerKey">The encrypted provider key used to enable the <see cref="ProviderUser"/>.</param>
|
||||
/// <param name="organizationKey">The encrypted organization key used to enable the <see cref="ProviderOrganization"/>.</param>
|
||||
/// <returns>The provider ID</returns>
|
||||
Task<Guid> FinalizeConversion(
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
string token,
|
||||
string providerKey,
|
||||
string organizationKey);
|
||||
|
||||
/// <summary>
|
||||
/// Begins the process of converting the <paramref name="organization"/> to a <see cref="ProviderType.BusinessUnit"/> by
|
||||
/// creating all the necessary database entities and sending a setup invitation to the <paramref name="providerAdminEmail"/>.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization to convert to a business unit.</param>
|
||||
/// <param name="providerAdminEmail">The email address of the organization member who will be the provider admin.</param>
|
||||
/// <returns>Either the newly created provider ID or a list of validation failures.</returns>
|
||||
Task<OneOf<Guid, List<string>>> InitiateConversion(
|
||||
Organization organization,
|
||||
string providerAdminEmail);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the <paramref name="organization"/> has a business unit conversion in progress and, if it does, resends the
|
||||
/// setup invitation to the provider admin.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization to convert to a business unit.</param>
|
||||
/// <param name="providerAdminEmail">The email address of the organization member who will be the provider admin.</param>
|
||||
Task ResendConversionInvite(
|
||||
Organization organization,
|
||||
string providerAdminEmail);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the <paramref name="organization"/> has a business unit conversion in progress and, if it does, resets that conversion
|
||||
/// by deleting all the database entities created as part of <see cref="InitiateConversion"/>.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization to convert to a business unit.</param>
|
||||
/// <param name="providerAdminEmail">The email address of the organization member who will be the provider admin.</param>
|
||||
Task ResetConversion(
|
||||
Organization organization,
|
||||
string providerAdminEmail);
|
||||
}
|
@ -147,6 +147,7 @@ public static class FeatureFlagKeys
|
||||
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
|
||||
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
|
||||
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
|
||||
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
|
||||
|
||||
/* Key Management Team */
|
||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||
|
@ -0,0 +1,19 @@
|
||||
{{#>FullHtmlLayout}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
|
||||
You have been invited to set up a new Business Unit Portal within Bitwarden.
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
|
||||
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
Set Up Business Unit Portal Now
|
||||
</a>
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/FullHtmlLayout}}
|
@ -0,0 +1,5 @@
|
||||
{{#>BasicTextLayout}}
|
||||
You have been invited to set up a new Business Unit Portal within Bitwarden. To continue, click the following link:
|
||||
|
||||
{{{Url}}}
|
||||
{{/BasicTextLayout}}
|
@ -0,0 +1,11 @@
|
||||
namespace Bit.Core.Models.Mail.Billing;
|
||||
|
||||
public class BusinessUnitConversionInviteModel : BaseMailModel
|
||||
{
|
||||
public string OrganizationId { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Token { get; set; }
|
||||
|
||||
public string Url =>
|
||||
$"{WebVaultUrl}/providers/setup-business-unit?organizationId={OrganizationId}&email={Email}&token={Token}";
|
||||
}
|
@ -70,6 +70,7 @@ public interface IMailService
|
||||
Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage);
|
||||
Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName);
|
||||
Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email);
|
||||
Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email);
|
||||
Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email);
|
||||
Task SendProviderConfirmedEmailAsync(string providerName, string email);
|
||||
Task SendProviderUserRemoved(string providerName, string email);
|
||||
|
@ -11,6 +11,7 @@ using Bit.Core.Billing.Models.Mail;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Models.Mail.Billing;
|
||||
using Bit.Core.Models.Mail.FamiliesForEnterprise;
|
||||
using Bit.Core.Models.Mail.Provider;
|
||||
using Bit.Core.SecretsManager.Models.Mail;
|
||||
@ -949,6 +950,22 @@ public class HandlebarsMailService : IMailService
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email)
|
||||
{
|
||||
var message = CreateDefaultMessage("Set Up Business Unit", email);
|
||||
var model = new BusinessUnitConversionInviteModel
|
||||
{
|
||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||
SiteName = _globalSettings.SiteName,
|
||||
OrganizationId = organization.Id.ToString(),
|
||||
Email = WebUtility.UrlEncode(email),
|
||||
Token = WebUtility.UrlEncode(token)
|
||||
};
|
||||
await AddMessageContentAsync(message, "Billing.BusinessUnitConversionInvite", model);
|
||||
message.Category = "BusinessUnitConversionInvite";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email)
|
||||
{
|
||||
var message = CreateDefaultMessage($"Join {providerName}", email);
|
||||
|
@ -212,6 +212,11 @@ public class NoopMailService : IMailService
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
|
@ -332,7 +332,7 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
|
||||
var planTypes = providerType switch
|
||||
{
|
||||
ProviderType.Msp => PlanConstants.EnterprisePlanTypes.Concat(PlanConstants.TeamsPlanTypes),
|
||||
ProviderType.MultiOrganizationEnterprise => PlanConstants.EnterprisePlanTypes,
|
||||
ProviderType.BusinessUnit => PlanConstants.EnterprisePlanTypes,
|
||||
_ => []
|
||||
};
|
||||
|
||||
|
@ -35,6 +35,7 @@ public class ProviderUserProviderDetailsReadByUserIdStatusQuery : IQuery<Provide
|
||||
Permissions = x.pu.Permissions,
|
||||
UseEvents = x.p.UseEvents,
|
||||
ProviderStatus = x.p.Status,
|
||||
ProviderType = x.p.Type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -75,25 +75,25 @@ public class ProvidersControllerTests
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region CreateMultiOrganizationEnterpriseAsync
|
||||
#region CreateBusinessUnitAsync
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_WithValidModel_CreatesProvider(
|
||||
CreateMultiOrganizationEnterpriseProviderModel model,
|
||||
public async Task CreateBusinessUnitAsync_WithValidModel_CreatesProvider(
|
||||
CreateBusinessUnitProviderModel model,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model);
|
||||
var actual = await sutProvider.Sut.CreateBusinessUnit(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(actual);
|
||||
await sutProvider.GetDependency<ICreateProviderCommand>()
|
||||
.Received(Quantity.Exactly(1))
|
||||
.CreateMultiOrganizationEnterpriseAsync(
|
||||
Arg.Is<Provider>(x => x.Type == ProviderType.MultiOrganizationEnterprise),
|
||||
.CreateBusinessUnitAsync(
|
||||
Arg.Is<Provider>(x => x.Type == ProviderType.BusinessUnit),
|
||||
model.OwnerEmail,
|
||||
Arg.Is<PlanType>(y => y == model.Plan),
|
||||
model.EnterpriseSeatMinimum);
|
||||
@ -102,16 +102,16 @@ public class ProvidersControllerTests
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_RedirectsToExpectedPage_AfterCreatingProvider(
|
||||
CreateMultiOrganizationEnterpriseProviderModel model,
|
||||
public async Task CreateBusinessUnitAsync_RedirectsToExpectedPage_AfterCreatingProvider(
|
||||
CreateBusinessUnitProviderModel model,
|
||||
Guid expectedProviderId,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<ICreateProviderCommand>()
|
||||
.When(x =>
|
||||
x.CreateMultiOrganizationEnterpriseAsync(
|
||||
Arg.Is<Provider>(y => y.Type == ProviderType.MultiOrganizationEnterprise),
|
||||
x.CreateBusinessUnitAsync(
|
||||
Arg.Is<Provider>(y => y.Type == ProviderType.BusinessUnit),
|
||||
model.OwnerEmail,
|
||||
Arg.Is<PlanType>(y => y == model.Plan),
|
||||
model.EnterpriseSeatMinimum))
|
||||
@ -122,7 +122,7 @@ public class ProvidersControllerTests
|
||||
});
|
||||
|
||||
// Act
|
||||
var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model);
|
||||
var actual = await sutProvider.Sut.CreateBusinessUnit(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(actual);
|
||||
|
Loading…
x
Reference in New Issue
Block a user