1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-24 12:35:25 +01:00

[PM-14401] Scale MSP on Admin client organization update (#5001)

* Privatize GetAssignedSeatTotalAsync

* Add SeatAdjustmentResultsInPurchase method

* Move adjustment logic to ProviderClientsController.Update

* Remove unused AssignSeatsToClientOrganization method

* Alphabetize ProviderBillingService

* Scale MSP on Admin client organization update

* Run dotnet format

* Patch build process

* Rui's feedback

---------

Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
Alex Morask 2024-11-12 14:11:10 -05:00 committed by GitHub
parent f2bf9ea9f8
commit a26ba3b330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1047 additions and 875 deletions

View File

@ -2,17 +2,14 @@
using Bit.Commercial.Core.Billing.Models; using Bit.Commercial.Core.Billing.Models;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing; using Bit.Core.Billing;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
@ -27,7 +24,6 @@ using Stripe;
namespace Bit.Commercial.Core.Billing; namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService( public class ProviderBillingService(
ICurrentContext currentContext,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger, ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -35,39 +31,77 @@ public class ProviderBillingService(
IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IProviderBillingService ISubscriberService subscriberService) : IProviderBillingService
{ {
public async Task AssignSeatsToClientOrganization( public async Task ChangePlan(ChangeProviderPlanCommand command)
Provider provider,
Organization organization,
int seats)
{ {
ArgumentNullException.ThrowIfNull(organization); var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
if (seats < 0) if (plan == null)
{ {
throw new BillingException( throw new BadRequestException("Provider plan not found.");
"You cannot assign negative seats to a client.",
"MSP cannot assign negative seats to a client organization");
} }
if (seats == organization.Seats) if (plan.PlanType == command.NewPlan)
{ {
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
return; return;
} }
var seatAdjustment = seats - (organization.Seats ?? 0); var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
await ScaleSeats(provider, organization.PlanType, seatAdjustment); plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
organization.Seats = seats; Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
await organizationRepository.ReplaceAsync(organization); await organizationRepository.ReplaceAsync(organization);
} }
}
public async Task CreateCustomerForClientOrganization( public async Task CreateCustomerForClientOrganization(
Provider provider, Provider provider,
@ -171,65 +205,16 @@ public class ProviderBillingService(
return memoryStream.ToArray(); return memoryStream.ToArray();
} }
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
Guid providerId,
PlanType planType)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogError(
"Could not find provider ({ID}) when retrieving assigned seat total",
providerId);
throw new BillingException();
}
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
throw new BillingException();
}
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
var plan = StaticStore.GetPlan(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
public async Task ScaleSeats( public async Task ScaleSeats(
Provider provider, Provider provider,
PlanType planType, PlanType planType,
int seatAdjustment) int seatAdjustment)
{ {
ArgumentNullException.ThrowIfNull(provider); var providerPlan = await GetProviderPlanAsync(provider, planType);
if (!provider.SupportsConsolidatedBilling()) var seatMinimum = providerPlan.SeatMinimum ?? 0;
{
logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id);
throw new BillingException(); var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
}
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
throw new BillingException();
}
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment; var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
@ -256,13 +241,6 @@ public class ProviderBillingService(
else if (currentlyAssignedSeatTotal <= seatMinimum && else if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal > seatMinimum) newlyAssignedSeatTotal > seatMinimum)
{ {
if (!currentContext.ProviderProviderAdmin(provider.Id))
{
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
throw new BillingException();
}
await update( await update(
seatMinimum, seatMinimum,
newlyAssignedSeatTotal); newlyAssignedSeatTotal);
@ -291,6 +269,26 @@ public class ProviderBillingService(
} }
} }
public async Task<bool> SeatAdjustmentResultsInPurchase(
Provider provider,
PlanType planType,
int seatAdjustment)
{
var providerPlan = await GetProviderPlanAsync(provider, planType);
var seatMinimum = providerPlan.SeatMinimum;
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
return
// Below the limit to above the limit
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
// Above the limit to further above the limit
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
}
public async Task<Customer> SetupCustomer( public async Task<Customer> SetupCustomer(
Provider provider, Provider provider,
TaxInfo taxInfo) TaxInfo taxInfo)
@ -431,75 +429,6 @@ public class ProviderBillingService(
} }
} }
public async Task ChangePlan(ChangeProviderPlanCommand command)
{
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
if (plan == null)
{
throw new BadRequestException("Provider plan not found.");
}
if (plan.PlanType == command.NewPlan)
{
return;
}
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
await organizationRepository.ReplaceAsync(organization);
}
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
{ {
if (command.Configuration.Any(x => x.SeatsMinimum < 0)) if (command.Configuration.Any(x => x.SeatsMinimum < 0))
@ -610,4 +539,32 @@ public class ProviderBillingService(
await providerPlanRepository.ReplaceAsync(providerPlan); await providerPlanRepository.ReplaceAsync(providerPlan);
}; };
// TODO: Replace with SPROC
private async Task<int> GetAssignedSeatTotalAsync(Provider provider, PlanType planType)
{
var providerOrganizations =
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
var plan = StaticStore.GetPlan(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
// TODO: Replace with SPROC
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanType planType)
{
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
throw new BillingException(message: "Provider plan is missing or misconfigured");
}
return providerPlan;
}
} }

View File

@ -5,8 +5,10 @@ using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
@ -230,7 +232,23 @@ public class OrganizationsController : Controller
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model) public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
{ {
var organization = await GetOrganization(id, model); var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
TempData["Error"] = "Could not find organization to update.";
return RedirectToAction("Index");
}
var existingOrganizationData = new Organization
{
Id = organization.Id,
Status = organization.Status,
PlanType = organization.PlanType,
Seats = organization.Seats
};
UpdateOrganization(organization, model);
if (organization.UseSecretsManager && if (organization.UseSecretsManager &&
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager) !StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
@ -239,7 +257,12 @@ public class OrganizationsController : Controller
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
} }
await HandlePotentialProviderSeatScalingAsync(
existingOrganizationData,
model);
await _organizationRepository.ReplaceAsync(organization); await _organizationRepository.ReplaceAsync(organization);
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext) await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
{ {
@ -394,10 +417,9 @@ public class OrganizationsController : Controller
return Json(null); return Json(null);
} }
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
{
var organization = await _organizationRepository.GetByIdAsync(id);
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
{
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox)) if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
{ {
organization.Enabled = model.Enabled; organization.Enabled = model.Enabled;
@ -449,7 +471,64 @@ public class OrganizationsController : Controller
organization.GatewayCustomerId = model.GatewayCustomerId; organization.GatewayCustomerId = model.GatewayCustomerId;
organization.GatewaySubscriptionId = model.GatewaySubscriptionId; organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
} }
}
return organization; private async Task HandlePotentialProviderSeatScalingAsync(
Organization organization,
OrganizationEditModel update)
{
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
var scaleMSPOnClientOrganizationUpdate =
_featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate);
if (!consolidatedBillingEnabled || !scaleMSPOnClientOrganizationUpdate)
{
return;
}
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
// No scaling required
if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } ||
organization is not { Status: OrganizationStatusType.Managed } ||
!organization.Seats.HasValue ||
update is { Seats: null, PlanType: null } ||
update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } ||
(PlanTypesMatch() && SeatsMatch()))
{
return;
}
// Only scale the plan
if (!PlanTypesMatch() && SeatsMatch())
{
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
}
// Only scale the seats
else if (PlanTypesMatch() && !SeatsMatch())
{
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment);
}
// Scale both
else if (!PlanTypesMatch() && !SeatsMatch())
{
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
var planTypeAdjustment = organization.Seats.Value;
var totalAdjustment = seatAdjustment + planTypeAdjustment;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment);
}
return;
bool PlanTypesMatch()
=> update.PlanType.HasValue && update.PlanType.Value == organization.PlanType;
bool SeatsMatch()
=> update.Seats.HasValue && update.Seats.Value == organization.Seats;
} }
} }

View File

@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
new ErrorResponseModel(message), new ErrorResponseModel(message),
statusCode: StatusCodes.Status500InternalServerError); statusCode: StatusCodes.Status500InternalServerError);
public static JsonHttpResult<ErrorResponseModel> Unauthorized() => public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
TypedResults.Json( TypedResults.Json(
new ErrorResponseModel("Unauthorized."), new ErrorResponseModel(message),
statusCode: StatusCodes.Status401Unauthorized); statusCode: StatusCodes.Status401Unauthorized);
} }
} }

View File

@ -102,15 +102,27 @@ public class ProviderClientsController(
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (clientOrganization.Seats != requestBody.AssignedSeats) if (clientOrganization is not { Status: OrganizationStatusType.Managed })
{ {
await providerBillingService.AssignSeatsToClientOrganization( return Error.ServerError();
provider,
clientOrganization,
requestBody.AssignedSeats);
} }
var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase(
provider,
clientOrganization.PlanType,
seatAdjustment);
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
{
return Error.Unauthorized("Service users cannot purchase additional seats.");
}
await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
clientOrganization.Name = requestBody.Name; clientOrganization.Name = requestBody.Name;
clientOrganization.Seats = requestBody.AssignedSeats;
await organizationRepository.ReplaceAsync(clientOrganization); await organizationRepository.ReplaceAsync(clientOrganization);

View File

@ -1,6 +1,5 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Contracts;
@ -12,18 +11,10 @@ namespace Bit.Core.Billing.Services;
public interface IProviderBillingService public interface IProviderBillingService
{ {
/// <summary> /// <summary>
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of /// Changes the assigned provider plan for the provider.
/// its <paramref name="provider"/>. Seat adjustments for the client organization may autoscale the provider's Stripe
/// <see cref="Stripe.Subscription"/> depending on the provider's seat minimum for the client <paramref name="organization"/>'s
/// <see cref="PlanType"/>.
/// </summary> /// </summary>
/// <param name="provider">The <see cref="Provider"/> that manages the client <paramref name="organization"/>.</param> /// <param name="command">The command to change the provider plan.</param>
/// <param name="organization">The client <see cref="Organization"/> whose <paramref name="seats"/> you want to update.</param> Task ChangePlan(ChangeProviderPlanCommand command);
/// <param name="seats">The number of seats to assign to the client organization.</param>
Task AssignSeatsToClientOrganization(
Provider provider,
Organization organization,
int seats);
/// <summary> /// <summary>
/// Create a Stripe <see cref="Stripe.Customer"/> for the provided client <paramref name="organization"/> utilizing /// Create a Stripe <see cref="Stripe.Customer"/> for the provided client <paramref name="organization"/> utilizing
@ -44,18 +35,6 @@ public interface IProviderBillingService
Task<byte[]> GenerateClientInvoiceReport( Task<byte[]> GenerateClientInvoiceReport(
string invoiceId); string invoiceId);
/// <summary>
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
/// </summary>
/// <param name="providerId">The ID of the MSP to retrieve the assigned seat total for.</param>
/// <param name="planType">The type of plan to retrieve the assigned seat total for.</param>
/// <returns>An <see cref="int"/> representing the number of seats the provider has assigned to its client organizations with the specified <paramref name="planType"/>.</returns>
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> is <see langword="null"/>.</exception>
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> has <see cref="Provider.Type"/> <see cref="ProviderType.Reseller"/>.</exception>
Task<int> GetAssignedSeatTotalForPlanOrThrow(
Guid providerId,
PlanType planType);
/// <summary> /// <summary>
/// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>. /// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>.
/// This operation may autoscale the provider's Stripe <see cref="Stripe.Subscription"/> depending on the <paramref name="provider"/>'s seat minimum for the /// This operation may autoscale the provider's Stripe <see cref="Stripe.Subscription"/> depending on the <paramref name="provider"/>'s seat minimum for the
@ -69,6 +48,22 @@ public interface IProviderBillingService
PlanType planType, PlanType planType,
int seatAdjustment); int seatAdjustment);
/// <summary>
/// Determines whether the provided <paramref name="seatAdjustment"/> will result in a purchase for the <paramref name="provider"/>'s <see cref="planType"/>.
/// Seat adjustments that result in purchases include:
/// <list type="bullet">
/// <item>The <paramref name="provider"/> going from below the seat minimum to above the seat minimum for the provided <paramref name="planType"/></item>
/// <item>The <paramref name="provider"/> going from above the seat minimum to further above the seat minimum for the provided <paramref name="planType"/></item>
/// </list>
/// </summary>
/// <param name="provider">The provider to check seat adjustments for.</param>
/// <param name="planType">The plan type to check seat adjustments for.</param>
/// <param name="seatAdjustment">The change in seats for the <paramref name="provider"/>'s <paramref name="planType"/>.</param>
Task<bool> SeatAdjustmentResultsInPurchase(
Provider provider,
PlanType planType,
int seatAdjustment);
/// <summary> /// <summary>
/// For use during the provider setup process, this method creates a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>. /// For use during the provider setup process, this method creates a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>.
/// </summary> /// </summary>
@ -90,12 +85,5 @@ public interface IProviderBillingService
Task<Subscription> SetupSubscription( Task<Subscription> SetupSubscription(
Provider provider); Provider provider);
/// <summary>
/// Changes the assigned provider plan for the provider.
/// </summary>
/// <param name="command">The command to change the provider plan.</param>
/// <returns></returns>
Task ChangePlan(ChangeProviderPlanCommand command);
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command); Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
} }

View File

@ -156,6 +156,7 @@ public static class FeatureFlagKeys
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
public const string SecurityTasks = "security-tasks"; public const string SecurityTasks = "security-tasks";
public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {

View File

@ -0,0 +1,343 @@
using Bit.Admin.AdminConsole.Controllers;
using Bit.Admin.AdminConsole.Models;
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.Enums;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
namespace Admin.Test.AdminConsole.Controllers;
[ControllerCustomize(typeof(OrganizationsController))]
[SutProviderCustomize]
public class OrganizationsControllerTests
{
#region Edit (POST)
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_RequiredFFDisabled_NoOp(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel { UseSecretsManager = false };
var organization = new Organization
{
Id = organizationId
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_NonBillableProvider_NoOp(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel { UseSecretsManager = false };
var organization = new Organization
{
Id = organizationId
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var featureService = sutProvider.GetDependency<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Created };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_UnmanagedOrganization_NoOp(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel { UseSecretsManager = false };
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Created
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var featureService = sutProvider.GetDependency<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_NonCBPlanType_NoOp(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 10,
PlanType = PlanType.FamiliesAnnually
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var featureService = sutProvider.GetDependency<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_NoUpdateRequired_NoOp(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 10,
PlanType = PlanType.EnterpriseMonthly
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10,
PlanType = PlanType.EnterpriseMonthly
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var featureService = sutProvider.GetDependency<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_PlanTypesUpdate_ScalesSeatsCorrectly(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 10,
PlanType = PlanType.EnterpriseMonthly
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10,
PlanType = PlanType.TeamsMonthly
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var featureService = sutProvider.GetDependency<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_SeatsUpdate_ScalesSeatsCorrectly(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 15,
PlanType = PlanType.EnterpriseMonthly
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10,
PlanType = PlanType.EnterpriseMonthly
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var featureService = sutProvider.GetDependency<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, update.Seats!.Value - organization.Seats.Value);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_FullUpdate_ScalesSeatsCorrectly(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 15,
PlanType = PlanType.EnterpriseMonthly
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10,
PlanType = PlanType.TeamsMonthly
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var featureService = sutProvider.GetDependency<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, update.Seats!.Value - organization.Seats.Value + organization.Seats.Value);
}
#endregion
}

View File

@ -5,8 +5,11 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -93,24 +96,7 @@ public class ProviderClientsControllerTests
#region UpdateAsync #region UpdateAsync
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateAsync_NoProviderOrganization_NotFound( public async Task UpdateAsync_ServiceUserMakingPurchase_Unauthorized(
Provider provider,
Guid providerOrganizationId,
UpdateClientOrganizationRequestBody requestBody,
SutProvider<ProviderClientsController> sutProvider)
{
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
.ReturnsNull();
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
AssertNotFound(result);
}
[Theory, BitAutoData]
public async Task UpdateAsync_AssignedSeats_Ok(
Provider provider, Provider provider,
Guid providerOrganizationId, Guid providerOrganizationId,
UpdateClientOrganizationRequestBody requestBody, UpdateClientOrganizationRequestBody requestBody,
@ -118,6 +104,11 @@ public class ProviderClientsControllerTests
Organization organization, Organization organization,
SutProvider<ProviderClientsController> sutProvider) SutProvider<ProviderClientsController> sutProvider)
{ {
organization.PlanType = PlanType.TeamsMonthly;
organization.Seats = 10;
organization.Status = OrganizationStatusType.Managed;
requestBody.AssignedSeats = 20;
ConfigureStableProviderServiceUserInputs(provider, sutProvider); ConfigureStableProviderServiceUserInputs(provider, sutProvider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId) sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
@ -126,49 +117,57 @@ public class ProviderClientsControllerTests
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId) sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
.Returns(organization); .Returns(organization);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
sutProvider.GetDependency<IProviderBillingService>().SeatAdjustmentResultsInPurchase(
provider,
PlanType.TeamsMonthly,
10).Returns(true);
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
AssertUnauthorized(result, message: "Service users cannot purchase additional seats.");
}
[Theory, BitAutoData]
public async Task UpdateAsync_Ok(
Provider provider,
Guid providerOrganizationId,
UpdateClientOrganizationRequestBody requestBody,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<ProviderClientsController> sutProvider)
{
organization.PlanType = PlanType.TeamsMonthly;
organization.Seats = 10;
organization.Status = OrganizationStatusType.Managed;
requestBody.AssignedSeats = 20;
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
.Returns(providerOrganization);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
.Returns(organization);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
sutProvider.GetDependency<IProviderBillingService>().SeatAdjustmentResultsInPurchase(
provider,
PlanType.TeamsMonthly,
10).Returns(false);
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody); var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
await sutProvider.GetDependency<IProviderBillingService>().Received(1) await sutProvider.GetDependency<IProviderBillingService>().Received(1)
.AssignSeatsToClientOrganization( .ScaleSeats(
provider, provider,
organization, PlanType.TeamsMonthly,
requestBody.AssignedSeats); 10);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1) await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
.ReplaceAsync(Arg.Is<Organization>(org => org.Name == requestBody.Name)); .ReplaceAsync(Arg.Is<Organization>(org => org.Seats == requestBody.AssignedSeats && org.Name == requestBody.Name));
Assert.IsType<Ok>(result);
}
[Theory, BitAutoData]
public async Task UpdateAsync_Name_Ok(
Provider provider,
Guid providerOrganizationId,
UpdateClientOrganizationRequestBody requestBody,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<ProviderClientsController> sutProvider)
{
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
.Returns(providerOrganization);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
.Returns(organization);
requestBody.AssignedSeats = organization.Seats!.Value;
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.AssignSeatsToClientOrganization(
Arg.Any<Provider>(),
Arg.Any<Organization>(),
Arg.Any<int>());
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
.ReplaceAsync(Arg.Is<Organization>(org => org.Name == requestBody.Name));
Assert.IsType<Ok>(result); Assert.IsType<Ok>(result);
} }

View File

@ -25,14 +25,14 @@ public static class Utilities
Assert.Equal("Resource not found.", response.Message); Assert.Equal("Resource not found.", response.Message);
} }
public static void AssertUnauthorized(IResult result) public static void AssertUnauthorized(IResult result, string message = "Unauthorized.")
{ {
Assert.IsType<JsonHttpResult<ErrorResponseModel>>(result); Assert.IsType<JsonHttpResult<ErrorResponseModel>>(result);
var response = (JsonHttpResult<ErrorResponseModel>)result; var response = (JsonHttpResult<ErrorResponseModel>)result;
Assert.Equal(StatusCodes.Status401Unauthorized, response.StatusCode); Assert.Equal(StatusCodes.Status401Unauthorized, response.StatusCode);
Assert.Equal("Unauthorized.", response.Value.Message); Assert.Equal(message, response.Value.Message);
} }
public static void ConfigureStableProviderAdminInputs<T>( public static void ConfigureStableProviderAdminInputs<T>(