-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[AC-1910] Allocate seats to a provider organization (#3936)
* Add endpoint to update a provider organization's seats for consolidated billing. * Fixed failing tests
- Loading branch information
1 parent
a95bb7c
commit 64d415d
Showing
28 changed files
with
1,106 additions
and
66 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
63 changes: 63 additions & 0 deletions
63
src/Api/Billing/Controllers/ProviderOrganizationController.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
using Bit.Api.Billing.Models; | ||
using Bit.Core; | ||
using Bit.Core.AdminConsole.Repositories; | ||
using Bit.Core.Billing.Commands; | ||
using Bit.Core.Context; | ||
using Bit.Core.Repositories; | ||
using Bit.Core.Services; | ||
using Microsoft.AspNetCore.Mvc; | ||
|
||
namespace Bit.Api.Billing.Controllers; | ||
|
||
[Route("providers/{providerId:guid}/organizations")] | ||
public class ProviderOrganizationController( | ||
IAssignSeatsToClientOrganizationCommand assignSeatsToClientOrganizationCommand, | ||
ICurrentContext currentContext, | ||
IFeatureService featureService, | ||
ILogger<ProviderOrganizationController> logger, | ||
IOrganizationRepository organizationRepository, | ||
IProviderRepository providerRepository, | ||
IProviderOrganizationRepository providerOrganizationRepository) : Controller | ||
{ | ||
[HttpPut("{providerOrganizationId:guid}")] | ||
public async Task<IResult> UpdateAsync( | ||
[FromRoute] Guid providerId, | ||
[FromRoute] Guid providerOrganizationId, | ||
[FromBody] UpdateProviderOrganizationRequestBody requestBody) | ||
{ | ||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) | ||
{ | ||
return TypedResults.NotFound(); | ||
} | ||
|
||
if (!currentContext.ProviderProviderAdmin(providerId)) | ||
{ | ||
return TypedResults.Unauthorized(); | ||
} | ||
|
||
var provider = await providerRepository.GetByIdAsync(providerId); | ||
|
||
var providerOrganization = await providerOrganizationRepository.GetByIdAsync(providerOrganizationId); | ||
|
||
if (provider == null || providerOrganization == null) | ||
{ | ||
return TypedResults.NotFound(); | ||
} | ||
|
||
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); | ||
|
||
if (organization == null) | ||
{ | ||
logger.LogError("The organization ({OrganizationID}) represented by provider organization ({ProviderOrganizationID}) could not be found.", providerOrganization.OrganizationId, providerOrganization.Id); | ||
|
||
return TypedResults.Problem(); | ||
} | ||
|
||
await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization( | ||
provider, | ||
organization, | ||
requestBody.AssignedSeats); | ||
|
||
return TypedResults.NoContent(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
6 changes: 6 additions & 0 deletions
6
src/Api/Billing/Models/UpdateProviderOrganizationRequestBody.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
namespace Bit.Api.Billing.Models; | ||
|
||
public class UpdateProviderOrganizationRequestBody | ||
{ | ||
public int AssignedSeats { get; set; } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 12 additions & 0 deletions
12
src/Core/Billing/Commands/IAssignSeatsToClientOrganizationCommand.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
using Bit.Core.AdminConsole.Entities; | ||
using Bit.Core.AdminConsole.Entities.Provider; | ||
|
||
namespace Bit.Core.Billing.Commands; | ||
|
||
public interface IAssignSeatsToClientOrganizationCommand | ||
{ | ||
Task AssignSeatsToClientOrganization( | ||
Provider provider, | ||
Organization organization, | ||
int seats); | ||
} |
174 changes: 174 additions & 0 deletions
174
src/Core/Billing/Commands/Implementations/AssignSeatsToClientOrganizationCommand.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
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.Extensions; | ||
using Bit.Core.Billing.Queries; | ||
using Bit.Core.Billing.Repositories; | ||
using Bit.Core.Repositories; | ||
using Bit.Core.Services; | ||
using Bit.Core.Utilities; | ||
using Microsoft.Extensions.Logging; | ||
using static Bit.Core.Billing.Utilities; | ||
|
||
namespace Bit.Core.Billing.Commands.Implementations; | ||
|
||
public class AssignSeatsToClientOrganizationCommand( | ||
ILogger<AssignSeatsToClientOrganizationCommand> logger, | ||
IOrganizationRepository organizationRepository, | ||
IPaymentService paymentService, | ||
IProviderBillingQueries providerBillingQueries, | ||
IProviderPlanRepository providerPlanRepository) : IAssignSeatsToClientOrganizationCommand | ||
{ | ||
public async Task AssignSeatsToClientOrganization( | ||
Provider provider, | ||
Organization organization, | ||
int seats) | ||
{ | ||
ArgumentNullException.ThrowIfNull(provider); | ||
ArgumentNullException.ThrowIfNull(organization); | ||
|
||
if (provider.Type == ProviderType.Reseller) | ||
{ | ||
logger.LogError("Reseller-type provider ({ID}) cannot assign seats to client organizations", provider.Id); | ||
|
||
throw ContactSupport("Consolidated billing does not support reseller-type providers"); | ||
} | ||
|
||
if (seats < 0) | ||
{ | ||
throw new BillingException( | ||
"You cannot assign negative seats to a client.", | ||
"MSP cannot assign negative seats to a client organization"); | ||
} | ||
|
||
if (seats == organization.Seats) | ||
{ | ||
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned", organization.Id, organization.Seats); | ||
|
||
return; | ||
} | ||
|
||
var providerPlan = await GetProviderPlanAsync(provider, organization); | ||
|
||
var providerSeatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0); | ||
|
||
// How many seats the provider has assigned to all their client organizations that have the specified plan type. | ||
var providerCurrentlyAssignedSeatTotal = await providerBillingQueries.GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType); | ||
|
||
// How many seats are being added to or subtracted from this client organization. | ||
var seatDifference = seats - (organization.Seats ?? 0); | ||
|
||
// How many seats the provider will have assigned to all of their client organizations after the update. | ||
var providerNewlyAssignedSeatTotal = providerCurrentlyAssignedSeatTotal + seatDifference; | ||
|
||
var update = CurryUpdateFunction( | ||
provider, | ||
providerPlan, | ||
organization, | ||
seats, | ||
providerNewlyAssignedSeatTotal); | ||
|
||
/* | ||
* Below the limit => Below the limit: | ||
* No subscription update required. We can safely update the organization's seats. | ||
*/ | ||
if (providerCurrentlyAssignedSeatTotal <= providerSeatMinimum && | ||
providerNewlyAssignedSeatTotal <= providerSeatMinimum) | ||
{ | ||
organization.Seats = seats; | ||
|
||
await organizationRepository.ReplaceAsync(organization); | ||
|
||
providerPlan.AllocatedSeats = providerNewlyAssignedSeatTotal; | ||
|
||
await providerPlanRepository.ReplaceAsync(providerPlan); | ||
} | ||
/* | ||
* Below the limit => Above the limit: | ||
* We have to scale the subscription up from the seat minimum to the newly assigned seat total. | ||
*/ | ||
else if (providerCurrentlyAssignedSeatTotal <= providerSeatMinimum && | ||
providerNewlyAssignedSeatTotal > providerSeatMinimum) | ||
{ | ||
await update( | ||
providerSeatMinimum, | ||
providerNewlyAssignedSeatTotal); | ||
} | ||
/* | ||
* Above the limit => Above the limit: | ||
* We have to scale the subscription from the currently assigned seat total to the newly assigned seat total. | ||
*/ | ||
else if (providerCurrentlyAssignedSeatTotal > providerSeatMinimum && | ||
providerNewlyAssignedSeatTotal > providerSeatMinimum) | ||
{ | ||
await update( | ||
providerCurrentlyAssignedSeatTotal, | ||
providerNewlyAssignedSeatTotal); | ||
} | ||
/* | ||
* Above the limit => Below the limit: | ||
* We have to scale the subscription down from the currently assigned seat total to the seat minimum. | ||
*/ | ||
else if (providerCurrentlyAssignedSeatTotal > providerSeatMinimum && | ||
providerNewlyAssignedSeatTotal <= providerSeatMinimum) | ||
{ | ||
await update( | ||
providerCurrentlyAssignedSeatTotal, | ||
providerSeatMinimum); | ||
} | ||
} | ||
|
||
// ReSharper disable once SuggestBaseTypeForParameter | ||
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, Organization organization) | ||
{ | ||
if (!organization.PlanType.SupportsConsolidatedBilling()) | ||
{ | ||
logger.LogError("Cannot assign seats to a client organization ({ID}) with a plan type that does not support consolidated billing: {PlanType}", organization.Id, organization.PlanType); | ||
|
||
throw ContactSupport(); | ||
} | ||
|
||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); | ||
|
||
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == organization.PlanType); | ||
|
||
if (providerPlan != null && providerPlan.IsConfigured()) | ||
{ | ||
return providerPlan; | ||
} | ||
|
||
logger.LogError("Cannot assign seats to client organization ({ClientOrganizationID}) when provider's ({ProviderID}) matching plan is not configured", organization.Id, provider.Id); | ||
|
||
throw ContactSupport(); | ||
} | ||
|
||
private Func<int, int, Task> CurryUpdateFunction( | ||
Provider provider, | ||
ProviderPlan providerPlan, | ||
Organization organization, | ||
int organizationNewlyAssignedSeats, | ||
int providerNewlyAssignedSeats) => async (providerCurrentlySubscribedSeats, providerNewlySubscribedSeats) => | ||
{ | ||
var plan = StaticStore.GetPlan(providerPlan.PlanType); | ||
await paymentService.AdjustSeats( | ||
provider, | ||
plan, | ||
providerCurrentlySubscribedSeats, | ||
providerNewlySubscribedSeats); | ||
organization.Seats = organizationNewlyAssignedSeats; | ||
await organizationRepository.ReplaceAsync(organization); | ||
var providerNewlyPurchasedSeats = providerNewlySubscribedSeats > providerPlan.SeatMinimum | ||
? providerNewlySubscribedSeats - providerPlan.SeatMinimum | ||
: 0; | ||
providerPlan.PurchasedSeats = providerNewlyPurchasedSeats; | ||
providerPlan.AllocatedSeats = providerNewlyAssignedSeats; | ||
await providerPlanRepository.ReplaceAsync(providerPlan); | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
using Bit.Core.Enums; | ||
|
||
namespace Bit.Core.Billing.Extensions; | ||
|
||
public static class BillingExtensions | ||
{ | ||
public static bool SupportsConsolidatedBilling(this PlanType planType) | ||
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.