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:
parent
c53e5eeab3
commit
e2cb406a95
@ -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();
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Api.Billing.Models;
|
||||||
|
|
||||||
|
public class UpdateProviderOrganizationRequestBody
|
||||||
|
{
|
||||||
|
public int AssignedSeats { get; set; }
|
||||||
|
}
|
@ -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();
|
||||||
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
9
src/Core/Billing/Extensions/BillingExtensions.cs
Normal file
9
src/Core/Billing/Extensions/BillingExtensions.cs
Normal 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;
|
||||||
|
}
|
@ -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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
61
src/Core/Models/Business/ProviderSubscriptionUpdate.cs
Normal file
61
src/Core/Models/Business/ProviderSubscriptionUpdate.cs
Normal 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
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user