1
0
mirror of https://github.com/bitwarden/server.git synced 2024-12-22 16:57:36 +01:00

[AC-1910] Allocate seats to a provider organization (#3936)

* Add endpoint to update a provider organization's seats for consolidated billing.

* Fixed failing tests
This commit is contained in:
Alex Morask 2024-03-29 11:18:10 -04:00 committed by GitHub
parent c53e5eeab3
commit e2cb406a95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1108 additions and 68 deletions

View File

@ -88,7 +88,7 @@ public class Startup
services.AddBaseServices(globalSettings); services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings); services.AddDefaultServices(globalSettings);
services.AddScoped<IAccessControlService, AccessControlService>(); services.AddScoped<IAccessControlService, AccessControlService>();
services.AddBillingCommands(); services.AddBillingOperations();
#if OSS #if OSS
services.AddOosServices(); services.AddOosServices();

View File

@ -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();
}
}

View File

@ -27,6 +27,7 @@ public record ProviderSubscriptionDTO(
plan.Name, plan.Name,
providerPlan.SeatMinimum, providerPlan.SeatMinimum,
providerPlan.PurchasedSeats, providerPlan.PurchasedSeats,
providerPlan.AssignedSeats,
cost, cost,
cadence); cadence);
}); });
@ -43,5 +44,6 @@ public record ProviderPlanDTO(
string PlanName, string PlanName,
int SeatMinimum, int SeatMinimum,
int PurchasedSeats, int PurchasedSeats,
int AssignedSeats,
decimal Cost, decimal Cost,
string Cadence); string Cadence);

View File

@ -0,0 +1,6 @@
namespace Bit.Api.Billing.Models;
public class UpdateProviderOrganizationRequestBody
{
public int AssignedSeats { get; set; }
}

View File

@ -170,8 +170,7 @@ public class Startup
services.AddDefaultServices(globalSettings); services.AddDefaultServices(globalSettings);
services.AddOrganizationSubscriptionServices(); services.AddOrganizationSubscriptionServices();
services.AddCoreLocalizationServices(); services.AddCoreLocalizationServices();
services.AddBillingCommands(); services.AddBillingOperations();
services.AddBillingQueries();
// Authorization Handlers // Authorization Handlers
services.AddAuthorizationHandlers(); services.AddAuthorizationHandlers();

View File

@ -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);
}

View File

@ -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);
};
}

View File

@ -11,6 +11,7 @@ public class ProviderPlan : ITableObject<Guid>
public PlanType PlanType { get; set; } public PlanType PlanType { get; set; }
public int? SeatMinimum { get; set; } public int? SeatMinimum { get; set; }
public int? PurchasedSeats { get; set; } public int? PurchasedSeats { get; set; }
public int? AllocatedSeats { get; set; }
public void SetNewId() public void SetNewId()
{ {
@ -20,5 +21,5 @@ public class ProviderPlan : ITableObject<Guid>
} }
} }
public bool Configured => SeatMinimum.HasValue && PurchasedSeats.HasValue; public bool IsConfigured() => SeatMinimum.HasValue && PurchasedSeats.HasValue && AllocatedSeats.HasValue;
} }

View File

@ -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;
}

View File

@ -9,15 +9,15 @@ using Microsoft.Extensions.DependencyInjection;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static void AddBillingCommands(this IServiceCollection services) public static void AddBillingOperations(this IServiceCollection services)
{ {
services.AddSingleton<ICancelSubscriptionCommand, CancelSubscriptionCommand>(); // Queries
services.AddSingleton<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>(); services.AddTransient<IProviderBillingQueries, ProviderBillingQueries>();
} services.AddTransient<ISubscriberQueries, SubscriberQueries>();
public static void AddBillingQueries(this IServiceCollection services) // Commands
{ services.AddTransient<IAssignSeatsToClientOrganizationCommand, AssignSeatsToClientOrganizationCommand>();
services.AddSingleton<IProviderBillingQueries, ProviderBillingQueries>(); services.AddTransient<ICancelSubscriptionCommand, CancelSubscriptionCommand>();
services.AddSingleton<ISubscriberQueries, SubscriberQueries>(); services.AddTransient<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>();
} }
} }

View File

@ -8,15 +8,17 @@ public record ConfiguredProviderPlan(
Guid ProviderId, Guid ProviderId,
PlanType PlanType, PlanType PlanType,
int SeatMinimum, int SeatMinimum,
int PurchasedSeats) int PurchasedSeats,
int AssignedSeats)
{ {
public static ConfiguredProviderPlan From(ProviderPlan providerPlan) => public static ConfiguredProviderPlan From(ProviderPlan providerPlan) =>
providerPlan.Configured providerPlan.IsConfigured()
? new ConfiguredProviderPlan( ? new ConfiguredProviderPlan(
providerPlan.Id, providerPlan.Id,
providerPlan.ProviderId, providerPlan.ProviderId,
providerPlan.PlanType, providerPlan.PlanType,
providerPlan.SeatMinimum.GetValueOrDefault(0), providerPlan.SeatMinimum.GetValueOrDefault(0),
providerPlan.PurchasedSeats.GetValueOrDefault(0)) providerPlan.PurchasedSeats.GetValueOrDefault(0),
providerPlan.AllocatedSeats.GetValueOrDefault(0))
: null; : null;
} }

View File

@ -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; namespace Bit.Core.Billing.Queries;
public interface IProviderBillingQueries 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> /// <summary>
/// Retrieves a provider's billing subscription data. /// Retrieves a provider's billing subscription data.
/// </summary> /// </summary>

View File

@ -1,17 +1,53 @@
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Billing.Queries.Implementations; namespace Bit.Core.Billing.Queries.Implementations;
public class ProviderBillingQueries( public class ProviderBillingQueries(
ILogger<ProviderBillingQueries> logger, ILogger<ProviderBillingQueries> logger,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository, IProviderRepository providerRepository,
ISubscriberQueries subscriberQueries) : IProviderBillingQueries ISubscriberQueries subscriberQueries) : IProviderBillingQueries
{ {
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 ContactSupport();
}
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
throw ContactSupport("Consolidated billing does not support reseller-type providers");
}
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
var plan = StaticStore.GetPlan(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
public async Task<ProviderSubscriptionData> GetSubscriptionData(Guid providerId) public async Task<ProviderSubscriptionData> GetSubscriptionData(Guid providerId)
{ {
var provider = await providerRepository.GetByIdAsync(providerId); var provider = await providerRepository.GetByIdAsync(providerId);
@ -25,6 +61,13 @@ public class ProviderBillingQueries(
return null; return null;
} }
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Subscription data cannot be retrieved for reseller-type provider ({ID})", providerId);
throw ContactSupport("Consolidated billing does not support reseller-type providers");
}
var subscription = await subscriberQueries.GetSubscription(provider, new SubscriptionGetOptions var subscription = await subscriberQueries.GetSubscription(provider, new SubscriptionGetOptions
{ {
Expand = ["customer"] Expand = ["customer"]
@ -38,7 +81,7 @@ public class ProviderBillingQueries(
var providerPlans = await providerPlanRepository.GetByProviderId(providerId); var providerPlans = await providerPlanRepository.GetByProviderId(providerId);
var configuredProviderPlans = providerPlans var configuredProviderPlans = providerPlans
.Where(providerPlan => providerPlan.Configured) .Where(providerPlan => providerPlan.IsConfigured())
.Select(ConfiguredProviderPlan.From) .Select(ConfiguredProviderPlan.From)
.ToList(); .ToList();

View File

@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Stripe; using Stripe;
@ -279,25 +278,6 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate
}; };
} }
private static SubscriptionItem FindSubscriptionItem(Subscription subscription, string planId)
{
if (string.IsNullOrEmpty(planId))
{
return null;
}
var data = subscription.Items.Data;
var subscriptionItem = data.FirstOrDefault(item => item.Plan?.Id == planId) ?? data.FirstOrDefault(item => item.Price?.Id == planId);
return subscriptionItem;
}
private static string GetPasswordManagerPlanId(StaticStore.Plan plan)
=> IsNonSeatBasedPlan(plan)
? plan.PasswordManager.StripePlanId
: plan.PasswordManager.StripeSeatPlanId;
private static SubscriptionData GetSubscriptionDataFor(Organization organization) private static SubscriptionData GetSubscriptionDataFor(Organization organization)
{ {
var plan = Utilities.StaticStore.GetPlan(organization.PlanType); var plan = Utilities.StaticStore.GetPlan(organization.PlanType);
@ -320,10 +300,4 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate
0 0
}; };
} }
private static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
=> plan.Type is
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
or PlanType.FamiliesAnnually
or PlanType.TeamsStarter;
} }

View File

@ -0,0 +1,61 @@
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Stripe;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Models.Business;
public class ProviderSubscriptionUpdate : SubscriptionUpdate
{
private readonly string _planId;
private readonly int _previouslyPurchasedSeats;
private readonly int _newlyPurchasedSeats;
protected override List<string> PlanIds => [_planId];
public ProviderSubscriptionUpdate(
PlanType planType,
int previouslyPurchasedSeats,
int newlyPurchasedSeats)
{
if (!planType.SupportsConsolidatedBilling())
{
throw ContactSupport($"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing");
}
_planId = GetPasswordManagerPlanId(Utilities.StaticStore.GetPlan(planType));
_previouslyPurchasedSeats = previouslyPurchasedSeats;
_newlyPurchasedSeats = newlyPurchasedSeats;
}
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
{
var subscriptionItem = FindSubscriptionItem(subscription, _planId);
return
[
new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = _planId,
Quantity = _previouslyPurchasedSeats
}
];
}
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
{
var subscriptionItem = FindSubscriptionItem(subscription, _planId);
return
[
new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = _planId,
Quantity = _newlyPurchasedSeats
}
];
}
}

View File

@ -18,7 +18,7 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription) public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
{ {
var item = SubscriptionItem(subscription, PlanIds.Single()); var item = FindSubscriptionItem(subscription, PlanIds.Single());
return new() return new()
{ {
new SubscriptionItemOptions new SubscriptionItemOptions
@ -34,7 +34,7 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription) public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
{ {
var item = SubscriptionItem(subscription, PlanIds.Single()); var item = FindSubscriptionItem(subscription, PlanIds.Single());
return new() return new()
{ {
new SubscriptionItemOptions new SubscriptionItemOptions

View File

@ -19,7 +19,7 @@ public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription) public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
{ {
var item = SubscriptionItem(subscription, PlanIds.Single()); var item = FindSubscriptionItem(subscription, PlanIds.Single());
_prevServiceAccounts = item?.Quantity ?? 0; _prevServiceAccounts = item?.Quantity ?? 0;
return new() return new()
{ {
@ -35,7 +35,7 @@ public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription) public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
{ {
var item = SubscriptionItem(subscription, PlanIds.Single()); var item = FindSubscriptionItem(subscription, PlanIds.Single());
return new() return new()
{ {
new SubscriptionItemOptions new SubscriptionItemOptions

View File

@ -19,7 +19,7 @@ public class SmSeatSubscriptionUpdate : SubscriptionUpdate
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription) public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
{ {
var item = SubscriptionItem(subscription, PlanIds.Single()); var item = FindSubscriptionItem(subscription, PlanIds.Single());
return new() return new()
{ {
new SubscriptionItemOptions new SubscriptionItemOptions
@ -35,7 +35,7 @@ public class SmSeatSubscriptionUpdate : SubscriptionUpdate
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription) public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
{ {
var item = SubscriptionItem(subscription, PlanIds.Single()); var item = FindSubscriptionItem(subscription, PlanIds.Single());
return new() return new()
{ {
new SubscriptionItemOptions new SubscriptionItemOptions

View File

@ -74,10 +74,10 @@ public class SponsorOrganizationSubscriptionUpdate : SubscriptionUpdate
private string AddStripePlanId => _applySponsorship ? _sponsoredPlanStripeId : _existingPlanStripeId; private string AddStripePlanId => _applySponsorship ? _sponsoredPlanStripeId : _existingPlanStripeId;
private Stripe.SubscriptionItem RemoveStripeItem(Subscription subscription) => private Stripe.SubscriptionItem RemoveStripeItem(Subscription subscription) =>
_applySponsorship ? _applySponsorship ?
SubscriptionItem(subscription, _existingPlanStripeId) : FindSubscriptionItem(subscription, _existingPlanStripeId) :
SubscriptionItem(subscription, _sponsoredPlanStripeId); FindSubscriptionItem(subscription, _sponsoredPlanStripeId);
private Stripe.SubscriptionItem AddStripeItem(Subscription subscription) => private Stripe.SubscriptionItem AddStripeItem(Subscription subscription) =>
_applySponsorship ? _applySponsorship ?
SubscriptionItem(subscription, _sponsoredPlanStripeId) : FindSubscriptionItem(subscription, _sponsoredPlanStripeId) :
SubscriptionItem(subscription, _existingPlanStripeId); FindSubscriptionItem(subscription, _existingPlanStripeId);
} }

View File

@ -17,7 +17,7 @@ public class StorageSubscriptionUpdate : SubscriptionUpdate
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription) public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
{ {
var item = SubscriptionItem(subscription, PlanIds.Single()); var item = FindSubscriptionItem(subscription, PlanIds.Single());
_prevStorage = item?.Quantity ?? 0; _prevStorage = item?.Quantity ?? 0;
return new() return new()
{ {
@ -38,7 +38,7 @@ public class StorageSubscriptionUpdate : SubscriptionUpdate
throw new Exception("Unknown previous value, must first call UpgradeItemsOptions"); throw new Exception("Unknown previous value, must first call UpgradeItemsOptions");
} }
var item = SubscriptionItem(subscription, PlanIds.Single()); var item = FindSubscriptionItem(subscription, PlanIds.Single());
return new() return new()
{ {
new SubscriptionItemOptions new SubscriptionItemOptions

View File

@ -1,4 +1,5 @@
using Stripe; using Bit.Core.Enums;
using Stripe;
namespace Bit.Core.Models.Business; namespace Bit.Core.Models.Business;
@ -15,7 +16,7 @@ public abstract class SubscriptionUpdate
foreach (var upgradeItemOptions in upgradeItemsOptions) foreach (var upgradeItemOptions in upgradeItemsOptions)
{ {
var upgradeQuantity = upgradeItemOptions.Quantity ?? 0; var upgradeQuantity = upgradeItemOptions.Quantity ?? 0;
var existingQuantity = SubscriptionItem(subscription, upgradeItemOptions.Plan)?.Quantity ?? 0; var existingQuantity = FindSubscriptionItem(subscription, upgradeItemOptions.Plan)?.Quantity ?? 0;
if (upgradeQuantity != existingQuantity) if (upgradeQuantity != existingQuantity)
{ {
return true; return true;
@ -24,6 +25,28 @@ public abstract class SubscriptionUpdate
return false; return false;
} }
protected static SubscriptionItem SubscriptionItem(Subscription subscription, string planId) => protected static SubscriptionItem FindSubscriptionItem(Subscription subscription, string planId)
planId == null ? null : subscription.Items?.Data?.FirstOrDefault(i => i.Plan.Id == planId); {
if (string.IsNullOrEmpty(planId))
{
return null;
}
var data = subscription.Items.Data;
var subscriptionItem = data.FirstOrDefault(item => item.Plan?.Id == planId) ?? data.FirstOrDefault(item => item.Price?.Id == planId);
return subscriptionItem;
}
protected static string GetPasswordManagerPlanId(StaticStore.Plan plan)
=> IsNonSeatBasedPlan(plan)
? plan.PasswordManager.StripePlanId
: plan.PasswordManager.StripeSeatPlanId;
protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
=> plan.Type is
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
or PlanType.FamiliesAnnually
or PlanType.TeamsStarter;
} }

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
@ -28,6 +29,12 @@ public interface IPaymentService
int newlyPurchasedAdditionalStorage, int newlyPurchasedAdditionalStorage,
DateTime? prorationDate = null); DateTime? prorationDate = null);
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
Task<string> AdjustSeats(
Provider provider,
Plan plan,
int currentlySubscribedSeats,
int newlySubscribedSeats,
DateTime? prorationDate = null);
Task<string> AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task<string> AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null); Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null);

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -757,14 +758,14 @@ public class StripePaymentService : IPaymentService
}).ToList(); }).ToList();
} }
private async Task<string> FinalizeSubscriptionChangeAsync(IStorableSubscriber storableSubscriber, private async Task<string> FinalizeSubscriptionChangeAsync(ISubscriber subscriber,
SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate, bool invoiceNow = false) SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate, bool invoiceNow = false)
{ {
// remember, when in doubt, throw // remember, when in doubt, throw
var subGetOptions = new SubscriptionGetOptions(); var subGetOptions = new SubscriptionGetOptions();
// subGetOptions.AddExpand("customer"); // subGetOptions.AddExpand("customer");
subGetOptions.AddExpand("customer.tax"); subGetOptions.AddExpand("customer.tax");
var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId, subGetOptions); var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions);
if (sub == null) if (sub == null)
{ {
throw new GatewayException("Subscription not found."); throw new GatewayException("Subscription not found.");
@ -792,8 +793,8 @@ public class StripePaymentService : IPaymentService
{ {
var upcomingInvoiceWithChanges = await _stripeAdapter.InvoiceUpcomingAsync(new UpcomingInvoiceOptions var upcomingInvoiceWithChanges = await _stripeAdapter.InvoiceUpcomingAsync(new UpcomingInvoiceOptions
{ {
Customer = storableSubscriber.GatewayCustomerId, Customer = subscriber.GatewayCustomerId,
Subscription = storableSubscriber.GatewaySubscriptionId, Subscription = subscriber.GatewaySubscriptionId,
SubscriptionItems = ToInvoiceSubscriptionItemOptions(updatedItemOptions), SubscriptionItems = ToInvoiceSubscriptionItemOptions(updatedItemOptions),
SubscriptionProrationBehavior = Constants.CreateProrations, SubscriptionProrationBehavior = Constants.CreateProrations,
SubscriptionProrationDate = prorationDate, SubscriptionProrationDate = prorationDate,
@ -862,7 +863,7 @@ public class StripePaymentService : IPaymentService
{ {
if (chargeNow) if (chargeNow)
{ {
paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(storableSubscriber, invoice); paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(subscriber, invoice);
} }
else else
{ {
@ -943,6 +944,17 @@ public class StripePaymentService : IPaymentService
return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);
} }
public Task<string> AdjustSeats(
Provider provider,
StaticStore.Plan plan,
int currentlySubscribedSeats,
int newlySubscribedSeats,
DateTime? prorationDate = null)
=> FinalizeSubscriptionChangeAsync(
provider,
new ProviderSubscriptionUpdate(plan.Type, currentlySubscribedSeats, newlySubscribedSeats),
prorationDate);
public Task<string> AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null) public Task<string> AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null)
{ {
return FinalizeSubscriptionChangeAsync(organization, new SmSeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); return FinalizeSubscriptionChangeAsync(organization, new SmSeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);

View File

@ -147,7 +147,6 @@ public static class StaticStore
public static Plan GetPlan(PlanType planType) => Plans.SingleOrDefault(p => p.Type == planType); public static Plan GetPlan(PlanType planType) => Plans.SingleOrDefault(p => p.Type == planType);
public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) => public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) =>
SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType); SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType);

View File

@ -0,0 +1,130 @@
using Bit.Api.Billing.Controllers;
using Bit.Api.Billing.Models;
using Bit.Core;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Queries;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http.HttpResults;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit;
namespace Bit.Api.Test.Billing.Controllers;
[ControllerCustomize(typeof(ProviderBillingController))]
[SutProviderCustomize]
public class ProviderBillingControllerTests
{
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_FFDisabled_NotFound(
Guid providerId,
SutProvider<ProviderBillingController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(false);
var result = await sutProvider.Sut.GetSubscriptionAsync(providerId);
Assert.IsType<NotFound>(result);
}
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_NotProviderAdmin_Unauthorized(
Guid providerId,
SutProvider<ProviderBillingController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
.Returns(false);
var result = await sutProvider.Sut.GetSubscriptionAsync(providerId);
Assert.IsType<UnauthorizedHttpResult>(result);
}
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_NoSubscriptionData_NotFound(
Guid providerId,
SutProvider<ProviderBillingController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
.Returns(true);
sutProvider.GetDependency<IProviderBillingQueries>().GetSubscriptionData(providerId).ReturnsNull();
var result = await sutProvider.Sut.GetSubscriptionAsync(providerId);
Assert.IsType<NotFound>(result);
}
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_OK(
Guid providerId,
SutProvider<ProviderBillingController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
.Returns(true);
var configuredPlans = new List<ConfiguredProviderPlan>
{
new (Guid.NewGuid(), providerId, PlanType.TeamsMonthly, 50, 10, 30),
new (Guid.NewGuid(), providerId, PlanType.EnterpriseMonthly, 100, 0, 90)
};
var subscription = new Subscription
{
Status = "active",
CurrentPeriodEnd = new DateTime(2025, 1, 1),
Customer = new Customer { Discount = new Discount { Coupon = new Coupon { PercentOff = 10 } } }
};
var providerSubscriptionData = new ProviderSubscriptionData(
configuredPlans,
subscription);
sutProvider.GetDependency<IProviderBillingQueries>().GetSubscriptionData(providerId)
.Returns(providerSubscriptionData);
var result = await sutProvider.Sut.GetSubscriptionAsync(providerId);
Assert.IsType<Ok<ProviderSubscriptionDTO>>(result);
var providerSubscriptionDTO = ((Ok<ProviderSubscriptionDTO>)result).Value;
Assert.Equal(providerSubscriptionDTO.Status, subscription.Status);
Assert.Equal(providerSubscriptionDTO.CurrentPeriodEndDate, subscription.CurrentPeriodEnd);
Assert.Equal(providerSubscriptionDTO.DiscountPercentage, subscription.Customer!.Discount!.Coupon!.PercentOff);
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var providerTeamsPlan = providerSubscriptionDTO.Plans.FirstOrDefault(plan => plan.PlanName == teamsPlan.Name);
Assert.NotNull(providerTeamsPlan);
Assert.Equal(50, providerTeamsPlan.SeatMinimum);
Assert.Equal(10, providerTeamsPlan.PurchasedSeats);
Assert.Equal(30, providerTeamsPlan.AssignedSeats);
Assert.Equal(60 * teamsPlan.PasswordManager.SeatPrice, providerTeamsPlan.Cost);
Assert.Equal("Monthly", providerTeamsPlan.Cadence);
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
var providerEnterprisePlan = providerSubscriptionDTO.Plans.FirstOrDefault(plan => plan.PlanName == enterprisePlan.Name);
Assert.NotNull(providerEnterprisePlan);
Assert.Equal(100, providerEnterprisePlan.SeatMinimum);
Assert.Equal(0, providerEnterprisePlan.PurchasedSeats);
Assert.Equal(90, providerEnterprisePlan.AssignedSeats);
Assert.Equal(100 * enterprisePlan.PasswordManager.SeatPrice, providerEnterprisePlan.Cost);
Assert.Equal("Monthly", providerEnterprisePlan.Cadence);
}
}

View File

@ -0,0 +1,168 @@
using Bit.Api.Billing.Controllers;
using Bit.Api.Billing.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http.HttpResults;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
using ProviderOrganization = Bit.Core.AdminConsole.Entities.Provider.ProviderOrganization;
namespace Bit.Api.Test.Billing.Controllers;
[ControllerCustomize(typeof(ProviderOrganizationController))]
[SutProviderCustomize]
public class ProviderOrganizationControllerTests
{
[Theory, BitAutoData]
public async Task UpdateAsync_FFDisabled_NotFound(
Guid providerId,
Guid providerOrganizationId,
UpdateProviderOrganizationRequestBody requestBody,
SutProvider<ProviderOrganizationController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(false);
var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody);
Assert.IsType<NotFound>(result);
}
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_NotProviderAdmin_Unauthorized(
Guid providerId,
Guid providerOrganizationId,
UpdateProviderOrganizationRequestBody requestBody,
SutProvider<ProviderOrganizationController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
.Returns(false);
var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody);
Assert.IsType<UnauthorizedHttpResult>(result);
}
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_NoProvider_NotFound(
Guid providerId,
Guid providerOrganizationId,
UpdateProviderOrganizationRequestBody requestBody,
SutProvider<ProviderOrganizationController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
.Returns(true);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(providerId)
.ReturnsNull();
var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody);
Assert.IsType<NotFound>(result);
}
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_NoProviderOrganization_NotFound(
Guid providerId,
Guid providerOrganizationId,
UpdateProviderOrganizationRequestBody requestBody,
Provider provider,
SutProvider<ProviderOrganizationController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
.Returns(true);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(providerId)
.Returns(provider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
.ReturnsNull();
var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody);
Assert.IsType<NotFound>(result);
}
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_NoOrganization_ServerError(
Guid providerId,
Guid providerOrganizationId,
UpdateProviderOrganizationRequestBody requestBody,
Provider provider,
ProviderOrganization providerOrganization,
SutProvider<ProviderOrganizationController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
.Returns(true);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(providerId)
.Returns(provider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
.Returns(providerOrganization);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
.ReturnsNull();
var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody);
Assert.IsType<ProblemHttpResult>(result);
}
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_NoContent(
Guid providerId,
Guid providerOrganizationId,
UpdateProviderOrganizationRequestBody requestBody,
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<ProviderOrganizationController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
.Returns(true);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(providerId)
.Returns(provider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
.Returns(providerOrganization);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
.Returns(organization);
var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody);
await sutProvider.GetDependency<IAssignSeatsToClientOrganizationCommand>().Received(1)
.AssignSeatsToClientOrganization(
provider,
organization,
requestBody.AssignedSeats);
Assert.IsType<NoContent>(result);
}
}

View File

@ -0,0 +1,339 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing;
using Bit.Core.Billing.Commands.Implementations;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Queries;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Models.StaticStore;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using static Bit.Core.Test.Billing.Utilities;
namespace Bit.Core.Test.Billing.Commands;
[SutProviderCustomize]
public class AssignSeatsToClientOrganizationCommandTests
{
[Theory, BitAutoData]
public Task AssignSeatsToClientOrganization_NullProvider_ArgumentNullException(
Organization organization,
int seats,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
=> Assert.ThrowsAsync<ArgumentNullException>(() =>
sutProvider.Sut.AssignSeatsToClientOrganization(null, organization, seats));
[Theory, BitAutoData]
public Task AssignSeatsToClientOrganization_NullOrganization_ArgumentNullException(
Provider provider,
int seats,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
=> Assert.ThrowsAsync<ArgumentNullException>(() =>
sutProvider.Sut.AssignSeatsToClientOrganization(provider, null, seats));
[Theory, BitAutoData]
public Task AssignSeatsToClientOrganization_NegativeSeats_BillingException(
Provider provider,
Organization organization,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
=> Assert.ThrowsAsync<BillingException>(() =>
sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, -5));
[Theory, BitAutoData]
public async Task AssignSeatsToClientOrganization_CurrentSeatsMatchesNewSeats_NoOp(
Provider provider,
Organization organization,
int seats,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
{
organization.PlanType = PlanType.TeamsMonthly;
organization.Seats = seats;
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
await sutProvider.GetDependency<IProviderPlanRepository>().DidNotReceive().GetByProviderId(provider.Id);
}
[Theory, BitAutoData]
public async Task AssignSeatsToClientOrganization_OrganizationPlanTypeDoesNotSupportConsolidatedBilling_ContactSupport(
Provider provider,
Organization organization,
int seats,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
{
organization.PlanType = PlanType.FamiliesAnnually;
await ThrowsContactSupportAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats));
}
[Theory, BitAutoData]
public async Task AssignSeatsToClientOrganization_ProviderPlanIsNotConfigured_ContactSupport(
Provider provider,
Organization organization,
int seats,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
{
organization.PlanType = PlanType.TeamsMonthly;
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(new List<ProviderPlan>
{
new ()
{
Id = Guid.NewGuid(),
PlanType = PlanType.TeamsMonthly,
ProviderId = provider.Id
}
});
await ThrowsContactSupportAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats));
}
[Theory, BitAutoData]
public async Task AssignSeatsToClientOrganization_BelowToBelow_Succeeds(
Provider provider,
Organization organization,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
{
organization.Seats = 10;
organization.PlanType = PlanType.TeamsMonthly;
// Scale up 10 seats
const int seats = 20;
var providerPlans = new List<ProviderPlan>
{
new()
{
Id = Guid.NewGuid(),
PlanType = PlanType.TeamsMonthly,
ProviderId = provider.Id,
PurchasedSeats = 0,
// 100 minimum
SeatMinimum = 100,
AllocatedSeats = 50
},
new()
{
Id = Guid.NewGuid(),
PlanType = PlanType.EnterpriseMonthly,
ProviderId = provider.Id,
PurchasedSeats = 0,
SeatMinimum = 500,
AllocatedSeats = 0
}
};
var providerPlan = providerPlans.First();
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
// 50 seats currently assigned with a seat minimum of 100
sutProvider.GetDependency<IProviderBillingQueries>().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(50);
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
await sutProvider.GetDependency<IPaymentService>().DidNotReceiveWithAnyArgs().AdjustSeats(
Arg.Any<Provider>(),
Arg.Any<Plan>(),
Arg.Any<int>(),
Arg.Any<int>());
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.Seats == seats));
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
pPlan => pPlan.AllocatedSeats == 60));
}
[Theory, BitAutoData]
public async Task AssignSeatsToClientOrganization_BelowToAbove_Succeeds(
Provider provider,
Organization organization,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
{
organization.Seats = 10;
organization.PlanType = PlanType.TeamsMonthly;
// Scale up 10 seats
const int seats = 20;
var providerPlans = new List<ProviderPlan>
{
new()
{
Id = Guid.NewGuid(),
PlanType = PlanType.TeamsMonthly,
ProviderId = provider.Id,
PurchasedSeats = 0,
// 100 minimum
SeatMinimum = 100,
AllocatedSeats = 95
},
new()
{
Id = Guid.NewGuid(),
PlanType = PlanType.EnterpriseMonthly,
ProviderId = provider.Id,
PurchasedSeats = 0,
SeatMinimum = 500,
AllocatedSeats = 0
}
};
var providerPlan = providerPlans.First();
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
// 95 seats currently assigned with a seat minimum of 100
sutProvider.GetDependency<IProviderBillingQueries>().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(95);
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
provider,
StaticStore.GetPlan(providerPlan.PlanType),
providerPlan.SeatMinimum!.Value,
105);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.Seats == seats));
// 105 total seats - 100 minimum = 5 purchased seats
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 5 && pPlan.AllocatedSeats == 105));
}
[Theory, BitAutoData]
public async Task AssignSeatsToClientOrganization_AboveToAbove_Succeeds(
Provider provider,
Organization organization,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
{
organization.Seats = 10;
organization.PlanType = PlanType.TeamsMonthly;
// Scale up 10 seats
const int seats = 20;
var providerPlans = new List<ProviderPlan>
{
new()
{
Id = Guid.NewGuid(),
PlanType = PlanType.TeamsMonthly,
ProviderId = provider.Id,
// 10 additional purchased seats
PurchasedSeats = 10,
// 100 seat minimum
SeatMinimum = 100,
AllocatedSeats = 110
},
new()
{
Id = Guid.NewGuid(),
PlanType = PlanType.EnterpriseMonthly,
ProviderId = provider.Id,
PurchasedSeats = 0,
SeatMinimum = 500,
AllocatedSeats = 0
}
};
var providerPlan = providerPlans.First();
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
// 110 seats currently assigned with a seat minimum of 100
sutProvider.GetDependency<IProviderBillingQueries>().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(110);
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
// 110 current + 10 seat scale up = 120 seats
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
provider,
StaticStore.GetPlan(providerPlan.PlanType),
110,
120);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.Seats == seats));
// 120 total seats - 100 seat minimum = 20 purchased seats
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 20 && pPlan.AllocatedSeats == 120));
}
[Theory, BitAutoData]
public async Task AssignSeatsToClientOrganization_AboveToBelow_Succeeds(
Provider provider,
Organization organization,
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
{
organization.Seats = 50;
organization.PlanType = PlanType.TeamsMonthly;
// Scale down 30 seats
const int seats = 20;
var providerPlans = new List<ProviderPlan>
{
new()
{
Id = Guid.NewGuid(),
PlanType = PlanType.TeamsMonthly,
ProviderId = provider.Id,
// 10 additional purchased seats
PurchasedSeats = 10,
// 100 seat minimum
SeatMinimum = 100,
AllocatedSeats = 110
},
new()
{
Id = Guid.NewGuid(),
PlanType = PlanType.EnterpriseMonthly,
ProviderId = provider.Id,
PurchasedSeats = 0,
SeatMinimum = 500,
AllocatedSeats = 0
}
};
var providerPlan = providerPlans.First();
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
// 110 seats currently assigned with a seat minimum of 100
sutProvider.GetDependency<IProviderBillingQueries>().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(110);
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
provider,
StaticStore.GetPlan(providerPlan.PlanType),
110,
providerPlan.SeatMinimum!.Value);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.Seats == seats));
// Being below the seat minimum means no purchased seats.
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 0 && pPlan.AllocatedSeats == 80));
}
}

View File

@ -87,7 +87,8 @@ public class ProviderBillingQueriesTests
ProviderId = providerId, ProviderId = providerId,
PlanType = PlanType.EnterpriseMonthly, PlanType = PlanType.EnterpriseMonthly,
SeatMinimum = 100, SeatMinimum = 100,
PurchasedSeats = 0 PurchasedSeats = 0,
AllocatedSeats = 0
}; };
var teamsPlan = new ProviderPlan var teamsPlan = new ProviderPlan
@ -96,7 +97,8 @@ public class ProviderBillingQueriesTests
ProviderId = providerId, ProviderId = providerId,
PlanType = PlanType.TeamsMonthly, PlanType = PlanType.TeamsMonthly,
SeatMinimum = 50, SeatMinimum = 50,
PurchasedSeats = 10 PurchasedSeats = 10,
AllocatedSeats = 60
}; };
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
@ -145,6 +147,7 @@ public class ProviderBillingQueriesTests
Assert.Equal(providerPlan.ProviderId, configuredProviderPlan.ProviderId); Assert.Equal(providerPlan.ProviderId, configuredProviderPlan.ProviderId);
Assert.Equal(providerPlan.SeatMinimum!.Value, configuredProviderPlan.SeatMinimum); Assert.Equal(providerPlan.SeatMinimum!.Value, configuredProviderPlan.SeatMinimum);
Assert.Equal(providerPlan.PurchasedSeats!.Value, configuredProviderPlan.PurchasedSeats); Assert.Equal(providerPlan.PurchasedSeats!.Value, configuredProviderPlan.PurchasedSeats);
Assert.Equal(providerPlan.AllocatedSeats!.Value, configuredProviderPlan.AssignedSeats);
} }
} }
#endregion #endregion