mirror of
https://github.com/bitwarden/server
synced 2025-12-23 11:43:23 +00: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:
@@ -5,8 +5,10 @@ using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
@@ -230,7 +232,23 @@ public class OrganizationsController : Controller
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
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 &&
|
||||
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
||||
@@ -239,7 +257,12 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
await HandlePotentialProviderSeatScalingAsync(
|
||||
existingOrganizationData,
|
||||
model);
|
||||
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
||||
{
|
||||
@@ -394,10 +417,9 @@ public class OrganizationsController : Controller
|
||||
|
||||
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))
|
||||
{
|
||||
organization.Enabled = model.Enabled;
|
||||
@@ -449,7 +471,64 @@ public class OrganizationsController : Controller
|
||||
organization.GatewayCustomerId = model.GatewayCustomerId;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
|
||||
new ErrorResponseModel(message),
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
|
||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized() =>
|
||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
||||
TypedResults.Json(
|
||||
new ErrorResponseModel("Unauthorized."),
|
||||
new ErrorResponseModel(message),
|
||||
statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,15 +102,27 @@ public class ProviderClientsController(
|
||||
|
||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
|
||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
||||
if (clientOrganization is not { Status: OrganizationStatusType.Managed })
|
||||
{
|
||||
await providerBillingService.AssignSeatsToClientOrganization(
|
||||
provider,
|
||||
clientOrganization,
|
||||
requestBody.AssignedSeats);
|
||||
return Error.ServerError();
|
||||
}
|
||||
|
||||
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.Seats = requestBody.AssignedSeats;
|
||||
|
||||
await organizationRepository.ReplaceAsync(clientOrganization);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
@@ -12,18 +11,10 @@ namespace Bit.Core.Billing.Services;
|
||||
public interface IProviderBillingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of
|
||||
/// 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"/>.
|
||||
/// Changes the assigned provider plan for the provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The <see cref="Provider"/> that manages the client <paramref name="organization"/>.</param>
|
||||
/// <param name="organization">The client <see cref="Organization"/> whose <paramref name="seats"/> you want to update.</param>
|
||||
/// <param name="seats">The number of seats to assign to the client organization.</param>
|
||||
Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats);
|
||||
/// <param name="command">The command to change the provider plan.</param>
|
||||
Task ChangePlan(ChangeProviderPlanCommand command);
|
||||
|
||||
/// <summary>
|
||||
/// 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(
|
||||
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>
|
||||
/// 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
|
||||
@@ -69,6 +48,22 @@ public interface IProviderBillingService
|
||||
PlanType planType,
|
||||
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>
|
||||
/// 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>
|
||||
@@ -90,12 +85,5 @@ public interface IProviderBillingService
|
||||
Task<Subscription> SetupSubscription(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ public static class FeatureFlagKeys
|
||||
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
|
||||
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
|
||||
public const string SecurityTasks = "security-tasks";
|
||||
public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user