Skip to content

Commit

Permalink
[AC-1910] Allocate seats to a provider organization (#3936)
Browse files Browse the repository at this point in the history
* Add endpoint to update a provider organization's seats for consolidated billing.

* Fixed failing tests
  • Loading branch information
amorask-bitwarden authored and cyprain-okeke committed Apr 1, 2024
1 parent a95bb7c commit 64d415d
Show file tree
Hide file tree
Showing 28 changed files with 1,106 additions and 66 deletions.
2 changes: 1 addition & 1 deletion src/Admin/Startup.cs
Expand Up @@ -88,7 +88,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.AddScoped<IAccessControlService, AccessControlService>();
services.AddBillingCommands();
services.AddBillingOperations();

#if OSS
services.AddOosServices();
Expand Down
63 changes: 63 additions & 0 deletions src/Api/Billing/Controllers/ProviderOrganizationController.cs
@@ -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();
}
}
2 changes: 2 additions & 0 deletions src/Api/Billing/Models/ProviderSubscriptionDTO.cs
Expand Up @@ -27,6 +27,7 @@ public record ProviderSubscriptionDTO(
plan.Name,
providerPlan.SeatMinimum,
providerPlan.PurchasedSeats,
providerPlan.AssignedSeats,
cost,
cadence);
});
Expand All @@ -43,5 +44,6 @@ public record ProviderPlanDTO(
string PlanName,
int SeatMinimum,
int PurchasedSeats,
int AssignedSeats,
decimal Cost,
string Cadence);
@@ -0,0 +1,6 @@
namespace Bit.Api.Billing.Models;

public class UpdateProviderOrganizationRequestBody
{
public int AssignedSeats { get; set; }
}
3 changes: 1 addition & 2 deletions src/Api/Startup.cs
Expand Up @@ -170,8 +170,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddDefaultServices(globalSettings);
services.AddOrganizationSubscriptionServices();
services.AddCoreLocalizationServices();
services.AddBillingCommands();
services.AddBillingQueries();
services.AddBillingOperations();

// Authorization Handlers
services.AddAuthorizationHandlers();
Expand Down
@@ -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);
}
@@ -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);
};
}
3 changes: 2 additions & 1 deletion src/Core/Billing/Entities/ProviderPlan.cs
Expand Up @@ -11,6 +11,7 @@ public class ProviderPlan : ITableObject<Guid>
public PlanType PlanType { get; set; }
public int? SeatMinimum { get; set; }
public int? PurchasedSeats { get; set; }
public int? AllocatedSeats { get; set; }

public void SetNewId()
{
Expand All @@ -20,5 +21,5 @@ public void SetNewId()
}
}

public bool Configured => SeatMinimum.HasValue && PurchasedSeats.HasValue;
public bool IsConfigured() => SeatMinimum.HasValue && PurchasedSeats.HasValue && AllocatedSeats.HasValue;
}
9 changes: 9 additions & 0 deletions src/Core/Billing/Extensions/BillingExtensions.cs
@@ -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;
}
16 changes: 8 additions & 8 deletions src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Expand Up @@ -9,15 +9,15 @@ namespace Bit.Core.Billing.Extensions;

public static class ServiceCollectionExtensions
{
public static void AddBillingCommands(this IServiceCollection services)
public static void AddBillingOperations(this IServiceCollection services)
{
services.AddSingleton<ICancelSubscriptionCommand, CancelSubscriptionCommand>();
services.AddSingleton<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>();
}
// Queries
services.AddTransient<IProviderBillingQueries, ProviderBillingQueries>();
services.AddTransient<ISubscriberQueries, SubscriberQueries>();

public static void AddBillingQueries(this IServiceCollection services)
{
services.AddSingleton<IProviderBillingQueries, ProviderBillingQueries>();
services.AddSingleton<ISubscriberQueries, SubscriberQueries>();
// Commands
services.AddTransient<IAssignSeatsToClientOrganizationCommand, AssignSeatsToClientOrganizationCommand>();
services.AddTransient<ICancelSubscriptionCommand, CancelSubscriptionCommand>();
services.AddTransient<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>();
}
}
8 changes: 5 additions & 3 deletions src/Core/Billing/Models/ConfiguredProviderPlan.cs
Expand Up @@ -8,15 +8,17 @@ public record ConfiguredProviderPlan(
Guid ProviderId,
PlanType PlanType,
int SeatMinimum,
int PurchasedSeats)
int PurchasedSeats,
int AssignedSeats)
{
public static ConfiguredProviderPlan From(ProviderPlan providerPlan) =>
providerPlan.Configured
providerPlan.IsConfigured()
? new ConfiguredProviderPlan(
providerPlan.Id,
providerPlan.ProviderId,
providerPlan.PlanType,
providerPlan.SeatMinimum.GetValueOrDefault(0),
providerPlan.PurchasedSeats.GetValueOrDefault(0))
providerPlan.PurchasedSeats.GetValueOrDefault(0),
providerPlan.AllocatedSeats.GetValueOrDefault(0))
: null;
}
15 changes: 14 additions & 1 deletion src/Core/Billing/Queries/IProviderBillingQueries.cs
@@ -1,9 +1,22 @@
using Bit.Core.Billing.Models;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;

namespace Bit.Core.Billing.Queries;

public interface IProviderBillingQueries
{
/// <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>
/// Retrieves a provider's billing subscription data.
/// </summary>
Expand Down

0 comments on commit 64d415d

Please sign in to comment.