mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +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:
parent
f2bf9ea9f8
commit
a26ba3b330
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>(
|
||||||
|
Loading…
Reference in New Issue
Block a user