mirror of
https://github.com/bitwarden/server.git
synced 2025-01-06 19:28:08 +01:00
Merge branch 'main' into ac/jmccannon/pm-10319-revoke-nc-users
This commit is contained in:
commit
47cc5456c1
35
.github/workflows/repository-management.yml
vendored
35
.github/workflows/repository-management.yml
vendored
@ -28,7 +28,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
branch: ${{ steps.set-branch.outputs.branch }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
steps:
|
||||
- name: Set branch
|
||||
id: set-branch
|
||||
@ -45,13 +44,6 @@ jobs:
|
||||
|
||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
|
||||
cut_branch:
|
||||
name: Cut branch
|
||||
@ -59,11 +51,18 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.target_ref }}
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
|
||||
env:
|
||||
@ -98,11 +97,18 @@ jobs:
|
||||
with:
|
||||
version: ${{ inputs.version_number_override }}
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
@ -190,11 +196,18 @@ jobs:
|
||||
- bump_version
|
||||
- setup
|
||||
steps:
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out main branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
@ -2,17 +2,14 @@
|
||||
using Bit.Commercial.Core.Billing.Models;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -27,7 +24,6 @@ using Stripe;
|
||||
namespace Bit.Commercial.Core.Billing;
|
||||
|
||||
public class ProviderBillingService(
|
||||
ICurrentContext currentContext,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -35,38 +31,76 @@ public class ProviderBillingService(
|
||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IProviderBillingService
|
||||
{
|
||||
public async Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats)
|
||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(organization);
|
||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
||||
|
||||
if (seats < 0)
|
||||
if (plan == null)
|
||||
{
|
||||
throw new BillingException(
|
||||
"You cannot assign negative seats to a client.",
|
||||
"MSP cannot assign negative seats to a client organization");
|
||||
throw new BadRequestException("Provider plan not found.");
|
||||
}
|
||||
|
||||
if (seats == organization.Seats)
|
||||
if (plan.PlanType == command.NewPlan)
|
||||
{
|
||||
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var seatAdjustment = seats - (organization.Seats ?? 0);
|
||||
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
|
||||
|
||||
await ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
||||
plan.PlanType = command.NewPlan;
|
||||
await providerPlanRepository.ReplaceAsync(plan);
|
||||
|
||||
organization.Seats = seats;
|
||||
Subscription subscription;
|
||||
try
|
||||
{
|
||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw new ConflictException("Subscription not found.");
|
||||
}
|
||||
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
||||
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
||||
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
|
||||
Quantity = oldSubscriptionItem!.Quantity
|
||||
},
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = oldSubscriptionItem.Id,
|
||||
Deleted = true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
||||
|
||||
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||
// 2. Assign PlanType & PlanName to Organization
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
||||
|
||||
foreach (var providerOrganization in providerOrganizations)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||
}
|
||||
organization.PlanType = command.NewPlan;
|
||||
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateCustomerForClientOrganization(
|
||||
@ -171,65 +205,16 @@ public class ProviderBillingService(
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
||||
Guid providerId,
|
||||
PlanType planType)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Could not find provider ({ID}) when retrieving assigned seat total",
|
||||
providerId);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
if (provider.Type == ProviderType.Reseller)
|
||||
{
|
||||
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
|
||||
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
|
||||
return providerOrganizations
|
||||
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
||||
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
||||
}
|
||||
|
||||
public async Task ScaleSeats(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
int seatAdjustment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||
|
||||
if (!provider.SupportsConsolidatedBilling())
|
||||
{
|
||||
logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id);
|
||||
var seatMinimum = providerPlan.SeatMinimum ?? 0;
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
|
||||
|
||||
if (providerPlan == null || !providerPlan.IsConfigured())
|
||||
{
|
||||
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
|
||||
|
||||
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
|
||||
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||
|
||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||
|
||||
@ -256,13 +241,6 @@ public class ProviderBillingService(
|
||||
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
||||
newlyAssignedSeatTotal > seatMinimum)
|
||||
{
|
||||
if (!currentContext.ProviderProviderAdmin(provider.Id))
|
||||
{
|
||||
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await update(
|
||||
seatMinimum,
|
||||
newlyAssignedSeatTotal);
|
||||
@ -291,6 +269,26 @@ public class ProviderBillingService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SeatAdjustmentResultsInPurchase(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
int seatAdjustment)
|
||||
{
|
||||
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||
|
||||
var seatMinimum = providerPlan.SeatMinimum;
|
||||
|
||||
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||
|
||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||
|
||||
return
|
||||
// Below the limit to above the limit
|
||||
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
|
||||
// Above the limit to further above the limit
|
||||
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
|
||||
}
|
||||
|
||||
public async Task<Customer> SetupCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
@ -431,75 +429,6 @@ public class ProviderBillingService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||
{
|
||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
||||
|
||||
if (plan == null)
|
||||
{
|
||||
throw new BadRequestException("Provider plan not found.");
|
||||
}
|
||||
|
||||
if (plan.PlanType == command.NewPlan)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
|
||||
|
||||
plan.PlanType = command.NewPlan;
|
||||
await providerPlanRepository.ReplaceAsync(plan);
|
||||
|
||||
Subscription subscription;
|
||||
try
|
||||
{
|
||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw new ConflictException("Subscription not found.");
|
||||
}
|
||||
|
||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
||||
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
||||
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
|
||||
Quantity = oldSubscriptionItem!.Quantity
|
||||
},
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = oldSubscriptionItem.Id,
|
||||
Deleted = true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
||||
|
||||
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||
// 2. Assign PlanType & PlanName to Organization
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
||||
|
||||
foreach (var providerOrganization in providerOrganizations)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||
}
|
||||
organization.PlanType = command.NewPlan;
|
||||
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||
{
|
||||
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
||||
@ -610,4 +539,32 @@ public class ProviderBillingService(
|
||||
|
||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
};
|
||||
|
||||
// TODO: Replace with SPROC
|
||||
private async Task<int> GetAssignedSeatTotalAsync(Provider provider, PlanType planType)
|
||||
{
|
||||
var providerOrganizations =
|
||||
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
|
||||
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
|
||||
return providerOrganizations
|
||||
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
||||
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
||||
}
|
||||
|
||||
// TODO: Replace with SPROC
|
||||
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanType planType)
|
||||
{
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType);
|
||||
|
||||
if (providerPlan == null || !providerPlan.IsConfigured())
|
||||
{
|
||||
throw new BillingException(message: "Provider plan is missing or misconfigured");
|
||||
}
|
||||
|
||||
return providerPlan;
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -5,8 +5,10 @@ using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
@ -230,7 +232,23 @@ public class OrganizationsController : Controller
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
|
||||
{
|
||||
var organization = await GetOrganization(id, model);
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
TempData["Error"] = "Could not find organization to update.";
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var existingOrganizationData = new Organization
|
||||
{
|
||||
Id = organization.Id,
|
||||
Status = organization.Status,
|
||||
PlanType = organization.PlanType,
|
||||
Seats = organization.Seats
|
||||
};
|
||||
|
||||
UpdateOrganization(organization, model);
|
||||
|
||||
if (organization.UseSecretsManager &&
|
||||
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
||||
@ -239,7 +257,12 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
await HandlePotentialProviderSeatScalingAsync(
|
||||
existingOrganizationData,
|
||||
model);
|
||||
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
||||
{
|
||||
@ -394,10 +417,9 @@ public class OrganizationsController : Controller
|
||||
|
||||
return Json(null);
|
||||
}
|
||||
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
|
||||
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
|
||||
{
|
||||
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
|
||||
{
|
||||
organization.Enabled = model.Enabled;
|
||||
@ -449,7 +471,64 @@ public class OrganizationsController : Controller
|
||||
organization.GatewayCustomerId = model.GatewayCustomerId;
|
||||
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
|
||||
}
|
||||
}
|
||||
|
||||
return organization;
|
||||
private async Task HandlePotentialProviderSeatScalingAsync(
|
||||
Organization organization,
|
||||
OrganizationEditModel update)
|
||||
{
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
var scaleMSPOnClientOrganizationUpdate =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate);
|
||||
|
||||
if (!consolidatedBillingEnabled || !scaleMSPOnClientOrganizationUpdate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
// No scaling required
|
||||
if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } ||
|
||||
organization is not { Status: OrganizationStatusType.Managed } ||
|
||||
!organization.Seats.HasValue ||
|
||||
update is { Seats: null, PlanType: null } ||
|
||||
update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } ||
|
||||
(PlanTypesMatch() && SeatsMatch()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only scale the plan
|
||||
if (!PlanTypesMatch() && SeatsMatch())
|
||||
{
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
|
||||
}
|
||||
// Only scale the seats
|
||||
else if (PlanTypesMatch() && !SeatsMatch())
|
||||
{
|
||||
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
||||
}
|
||||
// Scale both
|
||||
else if (!PlanTypesMatch() && !SeatsMatch())
|
||||
{
|
||||
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||
var planTypeAdjustment = organization.Seats.Value;
|
||||
var totalAdjustment = seatAdjustment + planTypeAdjustment;
|
||||
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
bool PlanTypesMatch()
|
||||
=> update.PlanType.HasValue && update.PlanType.Value == organization.PlanType;
|
||||
|
||||
bool SeatsMatch()
|
||||
=> update.Seats.HasValue && update.Seats.Value == organization.Seats;
|
||||
}
|
||||
}
|
||||
|
@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
|
||||
new ErrorResponseModel(message),
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
|
||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized() =>
|
||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
||||
TypedResults.Json(
|
||||
new ErrorResponseModel("Unauthorized."),
|
||||
new ErrorResponseModel(message),
|
||||
statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
}
|
||||
|
@ -102,15 +102,27 @@ public class ProviderClientsController(
|
||||
|
||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
|
||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
||||
if (clientOrganization is not { Status: OrganizationStatusType.Managed })
|
||||
{
|
||||
await providerBillingService.AssignSeatsToClientOrganization(
|
||||
provider,
|
||||
clientOrganization,
|
||||
requestBody.AssignedSeats);
|
||||
return Error.ServerError();
|
||||
}
|
||||
|
||||
var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
|
||||
|
||||
var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase(
|
||||
provider,
|
||||
clientOrganization.PlanType,
|
||||
seatAdjustment);
|
||||
|
||||
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
|
||||
{
|
||||
return Error.Unauthorized("Service users cannot purchase additional seats.");
|
||||
}
|
||||
|
||||
await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
|
||||
|
||||
clientOrganization.Name = requestBody.Name;
|
||||
clientOrganization.Seats = requestBody.AssignedSeats;
|
||||
|
||||
await organizationRepository.ReplaceAsync(clientOrganization);
|
||||
|
||||
|
@ -6,12 +6,14 @@ public record OrganizationMetadataResponse(
|
||||
bool IsEligibleForSelfHost,
|
||||
bool IsManaged,
|
||||
bool IsOnSecretsManagerStandalone,
|
||||
bool IsSubscriptionUnpaid)
|
||||
bool IsSubscriptionUnpaid,
|
||||
bool HasSubscription)
|
||||
{
|
||||
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
|
||||
=> new(
|
||||
metadata.IsEligibleForSelfHost,
|
||||
metadata.IsManaged,
|
||||
metadata.IsOnSecretsManagerStandalone,
|
||||
metadata.IsSubscriptionUnpaid);
|
||||
metadata.IsSubscriptionUnpaid,
|
||||
metadata.HasSubscription);
|
||||
}
|
||||
|
@ -14,7 +14,10 @@ public static class BillingExtensions
|
||||
provider.SupportsConsolidatedBilling() && provider.Status == ProviderStatusType.Billable;
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this Provider provider)
|
||||
=> provider.Type is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
=> provider.Type.SupportsConsolidatedBilling();
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
|
||||
public static bool IsValidClient(this Organization organization)
|
||||
=> organization is
|
||||
|
@ -4,4 +4,5 @@ public record OrganizationMetadata(
|
||||
bool IsEligibleForSelfHost,
|
||||
bool IsManaged,
|
||||
bool IsOnSecretsManagerStandalone,
|
||||
bool IsSubscriptionUnpaid);
|
||||
bool IsSubscriptionUnpaid,
|
||||
bool HasSubscription);
|
||||
|
@ -1,6 +1,5 @@
|
||||
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.Enums;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
@ -12,18 +11,10 @@ namespace Bit.Core.Billing.Services;
|
||||
public interface IProviderBillingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of
|
||||
/// its <paramref name="provider"/>. Seat adjustments for the client organization may autoscale the provider's Stripe
|
||||
/// <see cref="Stripe.Subscription"/> depending on the provider's seat minimum for the client <paramref name="organization"/>'s
|
||||
/// <see cref="PlanType"/>.
|
||||
/// Changes the assigned provider plan for the provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The <see cref="Provider"/> that manages the client <paramref name="organization"/>.</param>
|
||||
/// <param name="organization">The client <see cref="Organization"/> whose <paramref name="seats"/> you want to update.</param>
|
||||
/// <param name="seats">The number of seats to assign to the client organization.</param>
|
||||
Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats);
|
||||
/// <param name="command">The command to change the provider plan.</param>
|
||||
Task ChangePlan(ChangeProviderPlanCommand command);
|
||||
|
||||
/// <summary>
|
||||
/// Create a Stripe <see cref="Stripe.Customer"/> for the provided client <paramref name="organization"/> utilizing
|
||||
@ -44,18 +35,6 @@ public interface IProviderBillingService
|
||||
Task<byte[]> GenerateClientInvoiceReport(
|
||||
string invoiceId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
|
||||
/// </summary>
|
||||
/// <param name="providerId">The ID of the MSP to retrieve the assigned seat total for.</param>
|
||||
/// <param name="planType">The type of plan to retrieve the assigned seat total for.</param>
|
||||
/// <returns>An <see cref="int"/> representing the number of seats the provider has assigned to its client organizations with the specified <paramref name="planType"/>.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> has <see cref="Provider.Type"/> <see cref="ProviderType.Reseller"/>.</exception>
|
||||
Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
||||
Guid providerId,
|
||||
PlanType planType);
|
||||
|
||||
/// <summary>
|
||||
/// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>.
|
||||
/// This operation may autoscale the provider's Stripe <see cref="Stripe.Subscription"/> depending on the <paramref name="provider"/>'s seat minimum for the
|
||||
@ -69,6 +48,22 @@ public interface IProviderBillingService
|
||||
PlanType planType,
|
||||
int seatAdjustment);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the provided <paramref name="seatAdjustment"/> will result in a purchase for the <paramref name="provider"/>'s <see cref="planType"/>.
|
||||
/// Seat adjustments that result in purchases include:
|
||||
/// <list type="bullet">
|
||||
/// <item>The <paramref name="provider"/> going from below the seat minimum to above the seat minimum for the provided <paramref name="planType"/></item>
|
||||
/// <item>The <paramref name="provider"/> going from above the seat minimum to further above the seat minimum for the provided <paramref name="planType"/></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to check seat adjustments for.</param>
|
||||
/// <param name="planType">The plan type to check seat adjustments for.</param>
|
||||
/// <param name="seatAdjustment">The change in seats for the <paramref name="provider"/>'s <paramref name="planType"/>.</param>
|
||||
Task<bool> SeatAdjustmentResultsInPurchase(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
int seatAdjustment);
|
||||
|
||||
/// <summary>
|
||||
/// For use during the provider setup process, this method creates a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>.
|
||||
/// </summary>
|
||||
@ -90,12 +85,5 @@ public interface IProviderBillingService
|
||||
Task<Subscription> SetupSubscription(
|
||||
Provider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Changes the assigned provider plan for the provider.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to change the provider plan.</param>
|
||||
/// <returns></returns>
|
||||
Task ChangePlan(ChangeProviderPlanCommand command);
|
||||
|
||||
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
|
||||
}
|
||||
|
@ -62,18 +62,25 @@ public class OrganizationBillingService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
|
||||
var isManaged = organization.Status == OrganizationStatusType.Managed;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
|
||||
{
|
||||
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, false,
|
||||
false, false);
|
||||
}
|
||||
|
||||
var customer = await subscriberService.GetCustomer(organization,
|
||||
new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
|
||||
|
||||
var subscription = await subscriberService.GetSubscription(organization);
|
||||
|
||||
var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
|
||||
var isManaged = organization.Status == OrganizationStatusType.Managed;
|
||||
var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription);
|
||||
var isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription);
|
||||
var hasSubscription = true;
|
||||
|
||||
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone,
|
||||
isSubscriptionUnpaid);
|
||||
isSubscriptionUnpaid, hasSubscription);
|
||||
}
|
||||
|
||||
public async Task UpdatePaymentMethod(
|
||||
|
@ -111,7 +111,6 @@ public static class FeatureFlagKeys
|
||||
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
|
||||
public const string EmailVerification = "email-verification";
|
||||
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
||||
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
|
||||
public const string ExtensionRefresh = "extension-refresh";
|
||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
|
||||
@ -126,7 +125,6 @@ public static class FeatureFlagKeys
|
||||
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
|
||||
public const string SSHAgent = "ssh-agent";
|
||||
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
|
||||
public const string EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub";
|
||||
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
|
||||
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
|
||||
public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor";
|
||||
@ -156,6 +154,7 @@ public static class FeatureFlagKeys
|
||||
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
|
||||
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
|
||||
public const string SecurityTasks = "security-tasks";
|
||||
public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
|
@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Context;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Identity;
|
||||
@ -363,9 +364,9 @@ public class CurrentContext : ICurrentContext
|
||||
|
||||
public async Task<bool> ViewSubscription(Guid orgId)
|
||||
{
|
||||
var orgManagedByMspProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId && po.ProviderType == ProviderType.Msp);
|
||||
var isManagedByBillableProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId && po.ProviderType.SupportsConsolidatedBilling());
|
||||
|
||||
return orgManagedByMspProvider
|
||||
return isManagedByBillableProvider
|
||||
? await ProviderUserForOrgAsync(orgId)
|
||||
: await OrganizationOwner(orgId);
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Azure.NotificationHubs;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.NotificationHub;
|
||||
@ -60,21 +59,10 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService
|
||||
switch (type)
|
||||
{
|
||||
case DeviceType.Android:
|
||||
var featureService = _serviceProvider.GetRequiredService<IFeatureService>();
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.AnhFcmv1Migration))
|
||||
{
|
||||
payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
|
||||
messageTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
|
||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
||||
installation.Platform = NotificationPlatform.FcmV1;
|
||||
}
|
||||
else
|
||||
{
|
||||
payloadTemplate = "{\"data\":{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}}";
|
||||
messageTemplate = "{\"data\":{\"data\":{\"type\":\"#(type)\"}," +
|
||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
||||
installation.Platform = NotificationPlatform.Fcm;
|
||||
}
|
||||
payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
|
||||
messageTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
|
||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
||||
installation.Platform = NotificationPlatform.FcmV1;
|
||||
break;
|
||||
case DeviceType.iOS:
|
||||
payloadTemplate = "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," +
|
||||
|
@ -0,0 +1,343 @@
|
||||
using Bit.Admin.AdminConsole.Controllers;
|
||||
using Bit.Admin.AdminConsole.Models;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Admin.Test.AdminConsole.Controllers;
|
||||
|
||||
[ControllerCustomize(typeof(OrganizationsController))]
|
||||
[SutProviderCustomize]
|
||||
public class OrganizationsControllerTests
|
||||
{
|
||||
#region Edit (POST)
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_ProviderSeatScaling_RequiredFFDisabled_NoOp(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
var update = new OrganizationEditModel { UseSecretsManager = false };
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_ProviderSeatScaling_NonBillableProvider_NoOp(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
var update = new OrganizationEditModel { UseSecretsManager = false };
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||
|
||||
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||
|
||||
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Created };
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_ProviderSeatScaling_UnmanagedOrganization_NoOp(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
var update = new OrganizationEditModel { UseSecretsManager = false };
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Status = OrganizationStatusType.Created
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||
|
||||
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||
|
||||
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_ProviderSeatScaling_NonCBPlanType_NoOp(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
|
||||
var update = new OrganizationEditModel
|
||||
{
|
||||
UseSecretsManager = false,
|
||||
Seats = 10,
|
||||
PlanType = PlanType.FamiliesAnnually
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Status = OrganizationStatusType.Managed,
|
||||
Seats = 10
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||
|
||||
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||
|
||||
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_ProviderSeatScaling_NoUpdateRequired_NoOp(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
var update = new OrganizationEditModel
|
||||
{
|
||||
UseSecretsManager = false,
|
||||
Seats = 10,
|
||||
PlanType = PlanType.EnterpriseMonthly
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Status = OrganizationStatusType.Managed,
|
||||
Seats = 10,
|
||||
PlanType = PlanType.EnterpriseMonthly
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||
|
||||
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||
|
||||
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_ProviderSeatScaling_PlanTypesUpdate_ScalesSeatsCorrectly(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
var update = new OrganizationEditModel
|
||||
{
|
||||
UseSecretsManager = false,
|
||||
Seats = 10,
|
||||
PlanType = PlanType.EnterpriseMonthly
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Status = OrganizationStatusType.Managed,
|
||||
Seats = 10,
|
||||
PlanType = PlanType.TeamsMonthly
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||
|
||||
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||
|
||||
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||
await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_ProviderSeatScaling_SeatsUpdate_ScalesSeatsCorrectly(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
var update = new OrganizationEditModel
|
||||
{
|
||||
UseSecretsManager = false,
|
||||
Seats = 15,
|
||||
PlanType = PlanType.EnterpriseMonthly
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Status = OrganizationStatusType.Managed,
|
||||
Seats = 10,
|
||||
PlanType = PlanType.EnterpriseMonthly
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||
|
||||
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||
|
||||
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, update.Seats!.Value - organization.Seats.Value);
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_ProviderSeatScaling_FullUpdate_ScalesSeatsCorrectly(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
var update = new OrganizationEditModel
|
||||
{
|
||||
UseSecretsManager = false,
|
||||
Seats = 15,
|
||||
PlanType = PlanType.EnterpriseMonthly
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Status = OrganizationStatusType.Managed,
|
||||
Seats = 10,
|
||||
PlanType = PlanType.TeamsMonthly
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
var featureService = sutProvider.GetDependency<IFeatureService>();
|
||||
|
||||
featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate).Returns(true);
|
||||
|
||||
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||
await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, update.Seats!.Value - organization.Seats.Value + organization.Seats.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
@ -52,7 +52,7 @@ public class OrganizationBillingControllerTests
|
||||
{
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(organizationId).Returns(true);
|
||||
sutProvider.GetDependency<IOrganizationBillingService>().GetMetadata(organizationId)
|
||||
.Returns(new OrganizationMetadata(true, true, true, true));
|
||||
.Returns(new OrganizationMetadata(true, true, true, true, true));
|
||||
|
||||
var result = await sutProvider.Sut.GetMetadataAsync(organizationId);
|
||||
|
||||
@ -64,6 +64,7 @@ public class OrganizationBillingControllerTests
|
||||
Assert.True(response.IsManaged);
|
||||
Assert.True(response.IsOnSecretsManagerStandalone);
|
||||
Assert.True(response.IsSubscriptionUnpaid);
|
||||
Assert.True(response.HasSubscription);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
@ -5,8 +5,11 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -93,24 +96,7 @@ public class ProviderClientsControllerTests
|
||||
#region UpdateAsync
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_NoProviderOrganization_NotFound(
|
||||
Provider provider,
|
||||
Guid providerOrganizationId,
|
||||
UpdateClientOrganizationRequestBody requestBody,
|
||||
SutProvider<ProviderClientsController> sutProvider)
|
||||
{
|
||||
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
||||
.ReturnsNull();
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
|
||||
|
||||
AssertNotFound(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_AssignedSeats_Ok(
|
||||
public async Task UpdateAsync_ServiceUserMakingPurchase_Unauthorized(
|
||||
Provider provider,
|
||||
Guid providerOrganizationId,
|
||||
UpdateClientOrganizationRequestBody requestBody,
|
||||
@ -118,6 +104,11 @@ public class ProviderClientsControllerTests
|
||||
Organization organization,
|
||||
SutProvider<ProviderClientsController> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
organization.Seats = 10;
|
||||
organization.Status = OrganizationStatusType.Managed;
|
||||
requestBody.AssignedSeats = 20;
|
||||
|
||||
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
||||
@ -126,49 +117,57 @@ public class ProviderClientsControllerTests
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IProviderBillingService>().SeatAdjustmentResultsInPurchase(
|
||||
provider,
|
||||
PlanType.TeamsMonthly,
|
||||
10).Returns(true);
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
|
||||
|
||||
AssertUnauthorized(result, message: "Service users cannot purchase additional seats.");
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_Ok(
|
||||
Provider provider,
|
||||
Guid providerOrganizationId,
|
||||
UpdateClientOrganizationRequestBody requestBody,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
SutProvider<ProviderClientsController> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
organization.Seats = 10;
|
||||
organization.Status = OrganizationStatusType.Managed;
|
||||
requestBody.AssignedSeats = 20;
|
||||
|
||||
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
||||
.Returns(providerOrganization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IProviderBillingService>().SeatAdjustmentResultsInPurchase(
|
||||
provider,
|
||||
PlanType.TeamsMonthly,
|
||||
10).Returns(false);
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
|
||||
|
||||
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
|
||||
.AssignSeatsToClientOrganization(
|
||||
.ScaleSeats(
|
||||
provider,
|
||||
organization,
|
||||
requestBody.AssignedSeats);
|
||||
PlanType.TeamsMonthly,
|
||||
10);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Is<Organization>(org => org.Name == requestBody.Name));
|
||||
|
||||
Assert.IsType<Ok>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_Name_Ok(
|
||||
Provider provider,
|
||||
Guid providerOrganizationId,
|
||||
UpdateClientOrganizationRequestBody requestBody,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
SutProvider<ProviderClientsController> sutProvider)
|
||||
{
|
||||
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganizationId)
|
||||
.Returns(providerOrganization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(providerOrganization.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
requestBody.AssignedSeats = organization.Seats!.Value;
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody);
|
||||
|
||||
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||
.AssignSeatsToClientOrganization(
|
||||
Arg.Any<Provider>(),
|
||||
Arg.Any<Organization>(),
|
||||
Arg.Any<int>());
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Is<Organization>(org => org.Name == requestBody.Name));
|
||||
.ReplaceAsync(Arg.Is<Organization>(org => org.Seats == requestBody.AssignedSeats && org.Name == requestBody.Name));
|
||||
|
||||
Assert.IsType<Ok>(result);
|
||||
}
|
||||
|
@ -25,14 +25,14 @@ public static class Utilities
|
||||
Assert.Equal("Resource not found.", response.Message);
|
||||
}
|
||||
|
||||
public static void AssertUnauthorized(IResult result)
|
||||
public static void AssertUnauthorized(IResult result, string message = "Unauthorized.")
|
||||
{
|
||||
Assert.IsType<JsonHttpResult<ErrorResponseModel>>(result);
|
||||
|
||||
var response = (JsonHttpResult<ErrorResponseModel>)result;
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, response.StatusCode);
|
||||
Assert.Equal("Unauthorized.", response.Value.Message);
|
||||
Assert.Equal(message, response.Value.Message);
|
||||
}
|
||||
|
||||
public static void ConfigureStableProviderAdminInputs<T>(
|
||||
|
Loading…
Reference in New Issue
Block a user