mirror of
https://github.com/bitwarden/server.git
synced 2025-04-05 05:00:19 -05:00
Merge remote-tracking branch 'origin/main' into experiment/authorize-attribute
This commit is contained in:
commit
60f309f9ba
@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|
||||||
<Version>2025.3.3</Version>
|
<Version>2025.4.0</Version>
|
||||||
|
|
||||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
@ -127,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test"
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -319,6 +321,10 @@ Global
|
|||||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
|
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@ -370,6 +376,7 @@ Global
|
|||||||
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||||
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
@ -7,10 +8,12 @@ using Bit.Core.Billing.Constants;
|
|||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
|
||||||
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||||
@ -28,6 +31,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
|||||||
private readonly ISubscriberService _subscriberService;
|
private readonly ISubscriberService _subscriberService;
|
||||||
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
|
private readonly IAutomaticTaxStrategy _automaticTaxStrategy;
|
||||||
|
|
||||||
public RemoveOrganizationFromProviderCommand(
|
public RemoveOrganizationFromProviderCommand(
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
@ -40,7 +44,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
|||||||
IProviderBillingService providerBillingService,
|
IProviderBillingService providerBillingService,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||||
IPricingClient pricingClient)
|
IPricingClient pricingClient,
|
||||||
|
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
|
||||||
{
|
{
|
||||||
_eventService = eventService;
|
_eventService = eventService;
|
||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
@ -53,6 +58,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
|||||||
_subscriberService = subscriberService;
|
_subscriberService = subscriberService;
|
||||||
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
|
_automaticTaxStrategy = automaticTaxStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RemoveOrganizationFromProvider(
|
public async Task RemoveOrganizationFromProvider(
|
||||||
@ -107,10 +113,11 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
|||||||
organization.IsValidClient() &&
|
organization.IsValidClient() &&
|
||||||
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||||
{
|
{
|
||||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||||
{
|
{
|
||||||
Description = string.Empty,
|
Description = string.Empty,
|
||||||
Email = organization.BillingEmail
|
Email = organization.BillingEmail,
|
||||||
|
Expand = ["tax", "tax_ids"]
|
||||||
});
|
});
|
||||||
|
|
||||||
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
|
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
@ -120,7 +127,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
|||||||
Customer = organization.GatewayCustomerId,
|
Customer = organization.GatewayCustomerId,
|
||||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||||
DaysUntilDue = 30,
|
DaysUntilDue = 30,
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
{ "organizationId", organization.Id.ToString() }
|
{ "organizationId", organization.Id.ToString() }
|
||||||
@ -130,6 +136,18 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
|||||||
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
|
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
|
{
|
||||||
|
_automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||||
|
|
||||||
organization.GatewaySubscriptionId = subscription.Id;
|
organization.GatewaySubscriptionId = subscription.Id;
|
||||||
|
@ -14,6 +14,7 @@ using Bit.Core.Billing.Pricing;
|
|||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Billing.Services.Contracts;
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
|
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
@ -22,6 +23,7 @@ using Bit.Core.Services;
|
|||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
|
||||||
@ -29,10 +31,10 @@ namespace Bit.Commercial.Core.Billing;
|
|||||||
|
|
||||||
public class ProviderBillingService(
|
public class ProviderBillingService(
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
|
IFeatureService featureService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ILogger<ProviderBillingService> logger,
|
ILogger<ProviderBillingService> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IPaymentService paymentService,
|
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
@ -40,7 +42,9 @@ public class ProviderBillingService(
|
|||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
ITaxService taxService) : IProviderBillingService
|
ITaxService taxService,
|
||||||
|
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
|
||||||
|
: IProviderBillingService
|
||||||
{
|
{
|
||||||
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
|
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
|
||||||
public async Task AddExistingOrganization(
|
public async Task AddExistingOrganization(
|
||||||
@ -143,36 +147,29 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||||
{
|
{
|
||||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
var (provider, providerPlanId, newPlanType) = command;
|
||||||
|
|
||||||
if (plan == null)
|
var providerPlan = await providerPlanRepository.GetByIdAsync(providerPlanId);
|
||||||
|
|
||||||
|
if (providerPlan == null)
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Provider plan not found.");
|
throw new BadRequestException("Provider plan not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plan.PlanType == command.NewPlan)
|
if (providerPlan.PlanType == newPlanType)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType);
|
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||||
var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan);
|
|
||||||
|
|
||||||
plan.PlanType = command.NewPlan;
|
var oldPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||||
await providerPlanRepository.ReplaceAsync(plan);
|
var newPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, newPlanType);
|
||||||
|
|
||||||
Subscription subscription;
|
providerPlan.PlanType = newPlanType;
|
||||||
try
|
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||||
{
|
|
||||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
throw new ConflictException("Subscription not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => x.Price.Id == oldPriceId);
|
||||||
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
|
||||||
|
|
||||||
var updateOptions = new SubscriptionUpdateOptions
|
var updateOptions = new SubscriptionUpdateOptions
|
||||||
{
|
{
|
||||||
@ -180,7 +177,7 @@ public class ProviderBillingService(
|
|||||||
[
|
[
|
||||||
new SubscriptionItemOptions
|
new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId,
|
Price = newPriceId,
|
||||||
Quantity = oldSubscriptionItem!.Quantity
|
Quantity = oldSubscriptionItem!.Quantity
|
||||||
},
|
},
|
||||||
new SubscriptionItemOptions
|
new SubscriptionItemOptions
|
||||||
@ -191,12 +188,14 @@ public class ProviderBillingService(
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, updateOptions);
|
||||||
|
|
||||||
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||||
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||||
// 2. Assign PlanType & PlanName to Organization
|
// 2. Assign PlanType & PlanName to Organization
|
||||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId);
|
||||||
|
|
||||||
|
var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
|
||||||
|
|
||||||
foreach (var providerOrganization in providerOrganizations)
|
foreach (var providerOrganization in providerOrganizations)
|
||||||
{
|
{
|
||||||
@ -205,8 +204,8 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||||
}
|
}
|
||||||
organization.PlanType = command.NewPlan;
|
organization.PlanType = newPlanType;
|
||||||
organization.Plan = newPlanConfiguration.Name;
|
organization.Plan = newPlan.Name;
|
||||||
await organizationRepository.ReplaceAsync(organization);
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -400,7 +399,7 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||||
|
|
||||||
var update = CurrySeatScalingUpdate(
|
var scaleQuantityTo = CurrySeatScalingUpdate(
|
||||||
provider,
|
provider,
|
||||||
providerPlan,
|
providerPlan,
|
||||||
newlyAssignedSeatTotal);
|
newlyAssignedSeatTotal);
|
||||||
@ -423,9 +422,7 @@ public class ProviderBillingService(
|
|||||||
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
||||||
newlyAssignedSeatTotal > seatMinimum)
|
newlyAssignedSeatTotal > seatMinimum)
|
||||||
{
|
{
|
||||||
await update(
|
await scaleQuantityTo(newlyAssignedSeatTotal);
|
||||||
seatMinimum,
|
|
||||||
newlyAssignedSeatTotal);
|
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* Above the limit => Above the limit:
|
* Above the limit => Above the limit:
|
||||||
@ -434,9 +431,7 @@ public class ProviderBillingService(
|
|||||||
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
||||||
newlyAssignedSeatTotal > seatMinimum)
|
newlyAssignedSeatTotal > seatMinimum)
|
||||||
{
|
{
|
||||||
await update(
|
await scaleQuantityTo(newlyAssignedSeatTotal);
|
||||||
currentlyAssignedSeatTotal,
|
|
||||||
newlyAssignedSeatTotal);
|
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* Above the limit => Below the limit:
|
* Above the limit => Below the limit:
|
||||||
@ -445,9 +440,7 @@ public class ProviderBillingService(
|
|||||||
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
||||||
newlyAssignedSeatTotal <= seatMinimum)
|
newlyAssignedSeatTotal <= seatMinimum)
|
||||||
{
|
{
|
||||||
await update(
|
await scaleQuantityTo(seatMinimum);
|
||||||
currentlyAssignedSeatTotal,
|
|
||||||
seatMinimum);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -557,7 +550,8 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(provider);
|
ArgumentNullException.ThrowIfNull(provider);
|
||||||
|
|
||||||
var customer = await subscriberService.GetCustomerOrThrow(provider);
|
var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
|
||||||
|
var customer = await subscriberService.GetCustomerOrThrow(provider, customerGetOptions);
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||||
|
|
||||||
@ -580,19 +574,17 @@ public class ProviderBillingService(
|
|||||||
throw new BillingException();
|
throw new BillingException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var priceId = ProviderPriceAdapter.GetActivePriceId(provider, providerPlan.PlanType);
|
||||||
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
|
Price = priceId,
|
||||||
Quantity = providerPlan.SeatMinimum
|
Quantity = providerPlan.SeatMinimum
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||||
{
|
{
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
|
||||||
{
|
|
||||||
Enabled = true
|
|
||||||
},
|
|
||||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||||
Customer = customer.Id,
|
Customer = customer.Id,
|
||||||
DaysUntilDue = 30,
|
DaysUntilDue = 30,
|
||||||
@ -605,6 +597,15 @@ public class ProviderBillingService(
|
|||||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
|
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
|
{
|
||||||
|
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||||
@ -643,43 +644,37 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||||
{
|
{
|
||||||
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
var (provider, updatedPlanConfigurations) = command;
|
||||||
|
|
||||||
|
if (updatedPlanConfigurations.Any(x => x.SeatsMinimum < 0))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Subscription subscription;
|
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||||
try
|
|
||||||
{
|
|
||||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
throw new ConflictException("Subscription not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
|
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||||
|
|
||||||
foreach (var newPlanConfiguration in command.Configuration)
|
foreach (var updatedPlanConfiguration in updatedPlanConfigurations)
|
||||||
{
|
{
|
||||||
|
var (updatedPlanType, updatedSeatMinimum) = updatedPlanConfiguration;
|
||||||
|
|
||||||
var providerPlan =
|
var providerPlan =
|
||||||
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
|
providerPlans.Single(providerPlan => providerPlan.PlanType == updatedPlanType);
|
||||||
|
|
||||||
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
|
if (providerPlan.SeatMinimum != updatedSeatMinimum)
|
||||||
{
|
{
|
||||||
var newPlan = await pricingClient.GetPlanOrThrow(newPlanConfiguration.Plan);
|
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, updatedPlanType);
|
||||||
|
|
||||||
var priceId = newPlan.PasswordManager.StripeProviderPortalSeatPlanId;
|
|
||||||
|
|
||||||
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
|
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
|
||||||
|
|
||||||
if (providerPlan.PurchasedSeats == 0)
|
if (providerPlan.PurchasedSeats == 0)
|
||||||
{
|
{
|
||||||
if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
|
if (providerPlan.AllocatedSeats > updatedSeatMinimum)
|
||||||
{
|
{
|
||||||
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
|
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - updatedSeatMinimum;
|
||||||
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
@ -694,7 +689,7 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
Id = subscriptionItem.Id,
|
Id = subscriptionItem.Id,
|
||||||
Price = priceId,
|
Price = priceId,
|
||||||
Quantity = newPlanConfiguration.SeatsMinimum
|
Quantity = updatedSeatMinimum
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -702,9 +697,9 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
|
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
|
||||||
|
|
||||||
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
|
if (updatedSeatMinimum <= totalSeats)
|
||||||
{
|
{
|
||||||
providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
|
providerPlan.PurchasedSeats = totalSeats - updatedSeatMinimum;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -713,12 +708,12 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
Id = subscriptionItem.Id,
|
Id = subscriptionItem.Id,
|
||||||
Price = priceId,
|
Price = priceId,
|
||||||
Quantity = newPlanConfiguration.SeatsMinimum
|
Quantity = updatedSeatMinimum
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
|
providerPlan.SeatMinimum = updatedSeatMinimum;
|
||||||
|
|
||||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||||
}
|
}
|
||||||
@ -726,23 +721,33 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
if (subscriptionItemOptionsList.Count > 0)
|
if (subscriptionItemOptionsList.Count > 0)
|
||||||
{
|
{
|
||||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
|
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||||
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Func<int, int, Task> CurrySeatScalingUpdate(
|
private Func<int, Task> CurrySeatScalingUpdate(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
ProviderPlan providerPlan,
|
ProviderPlan providerPlan,
|
||||||
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
|
int newlyAssignedSeats) => async newlySubscribedSeats =>
|
||||||
{
|
{
|
||||||
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
|
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||||
|
|
||||||
await paymentService.AdjustSeats(
|
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||||
provider,
|
|
||||||
plan,
|
var item = subscription.Items.First(item => item.Price.Id == priceId);
|
||||||
currentlySubscribedSeats,
|
|
||||||
newlySubscribedSeats);
|
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
Items = [
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Price = priceId,
|
||||||
|
Quantity = newlySubscribedSeats
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
|
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
|
||||||
? newlySubscribedSeats - providerPlan.SeatMinimum
|
? newlySubscribedSeats - providerPlan.SeatMinimum
|
||||||
|
@ -0,0 +1,133 @@
|
|||||||
|
// ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
|
||||||
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.Billing;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
|
public static class ProviderPriceAdapter
|
||||||
|
{
|
||||||
|
public static class MSP
|
||||||
|
{
|
||||||
|
public static class Active
|
||||||
|
{
|
||||||
|
public const string Enterprise = "provider-portal-enterprise-monthly-2025";
|
||||||
|
public const string Teams = "provider-portal-teams-monthly-2025";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Legacy
|
||||||
|
{
|
||||||
|
public const string Enterprise = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||||
|
public const string Teams = "password-manager-provider-portal-teams-monthly-2024";
|
||||||
|
public static readonly List<string> List = [Enterprise, Teams];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class BusinessUnit
|
||||||
|
{
|
||||||
|
public static class Active
|
||||||
|
{
|
||||||
|
public const string Annually = "business-unit-portal-enterprise-annually-2025";
|
||||||
|
public const string Monthly = "business-unit-portal-enterprise-monthly-2025";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Legacy
|
||||||
|
{
|
||||||
|
public const string Annually = "password-manager-provider-portal-enterprise-annually-2024";
|
||||||
|
public const string Monthly = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||||
|
public static readonly List<string> List = [Annually, Monthly];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> and <paramref name="subscription"/> to determine
|
||||||
|
/// whether the <paramref name="provider"/> is on active or legacy pricing and then returns a Stripe price ID for the provided
|
||||||
|
/// <paramref name="planType"/> based on that determination.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||||
|
/// <param name="subscription">The provider's subscription.</param>
|
||||||
|
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||||
|
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||||
|
public static string GetPriceId(
|
||||||
|
Provider provider,
|
||||||
|
Subscription subscription,
|
||||||
|
PlanType planType)
|
||||||
|
{
|
||||||
|
var priceIds = subscription.Items.Select(item => item.Price.Id);
|
||||||
|
|
||||||
|
var invalidPlanType =
|
||||||
|
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||||
|
|
||||||
|
return provider.Type switch
|
||||||
|
{
|
||||||
|
ProviderType.Msp => MSP.Legacy.List.Intersect(priceIds).Any()
|
||||||
|
? planType switch
|
||||||
|
{
|
||||||
|
PlanType.TeamsMonthly => MSP.Legacy.Teams,
|
||||||
|
PlanType.EnterpriseMonthly => MSP.Legacy.Enterprise,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
}
|
||||||
|
: planType switch
|
||||||
|
{
|
||||||
|
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||||
|
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
|
||||||
|
? planType switch
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually,
|
||||||
|
PlanType.EnterpriseMonthly => BusinessUnit.Legacy.Monthly,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
}
|
||||||
|
: planType switch
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||||
|
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
_ => throw new BillingException(
|
||||||
|
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> to return the active Stripe price ID for the provided
|
||||||
|
/// <paramref name="planType"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||||
|
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||||
|
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||||
|
public static string GetActivePriceId(
|
||||||
|
Provider provider,
|
||||||
|
PlanType planType)
|
||||||
|
{
|
||||||
|
var invalidPlanType =
|
||||||
|
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||||
|
|
||||||
|
return provider.Type switch
|
||||||
|
{
|
||||||
|
ProviderType.Msp => planType switch
|
||||||
|
{
|
||||||
|
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||||
|
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
ProviderType.MultiOrganizationEnterprise => planType switch
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||||
|
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
_ => throw new BillingException(
|
||||||
|
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -228,6 +228,26 @@ public class RemoveOrganizationFromProviderCommandTests
|
|||||||
Id = "subscription_id"
|
Id = "subscription_id"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||||
|
.When(x => x.SetCreateOptions(
|
||||||
|
Arg.Is<SubscriptionCreateOptions>(options =>
|
||||||
|
options.Customer == organization.GatewayCustomerId &&
|
||||||
|
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
|
||||||
|
options.DaysUntilDue == 30 &&
|
||||||
|
options.Metadata["organizationId"] == organization.Id.ToString() &&
|
||||||
|
options.OffSession == true &&
|
||||||
|
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||||
|
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
|
||||||
|
options.Items.First().Quantity == organization.Seats)
|
||||||
|
, Arg.Any<Customer>()))
|
||||||
|
.Do(x =>
|
||||||
|
{
|
||||||
|
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||||
|
|
||||||
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||||
|
@ -4,6 +4,7 @@ using Bit.Commercial.Core.Billing;
|
|||||||
using Bit.Commercial.Core.Billing.Models;
|
using Bit.Commercial.Core.Billing.Models;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
@ -115,6 +116,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.MultiOrganizationEnterprise;
|
||||||
|
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
var existingPlan = new ProviderPlan
|
var existingPlan = new ProviderPlan
|
||||||
{
|
{
|
||||||
@ -132,10 +135,7 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
|
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(
|
|
||||||
Arg.Is(provider.GatewaySubscriptionId),
|
|
||||||
Arg.Is(provider.Id))
|
|
||||||
.Returns(new Subscription
|
.Returns(new Subscription
|
||||||
{
|
{
|
||||||
Id = provider.GatewaySubscriptionId,
|
Id = provider.GatewaySubscriptionId,
|
||||||
@ -158,7 +158,7 @@ public class ProviderBillingServiceTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
var command =
|
var command =
|
||||||
new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId);
|
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
|
||||||
.Returns(StaticStore.GetPlan(command.NewPlan));
|
.Returns(StaticStore.GetPlan(command.NewPlan));
|
||||||
@ -170,6 +170,8 @@ public class ProviderBillingServiceTests
|
|||||||
await providerPlanRepository.Received(1)
|
await providerPlanRepository.Received(1)
|
||||||
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
|
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
await stripeAdapter.Received(1)
|
await stripeAdapter.Received(1)
|
||||||
.SubscriptionUpdateAsync(
|
.SubscriptionUpdateAsync(
|
||||||
Arg.Is(provider.GatewaySubscriptionId),
|
Arg.Is(provider.GatewaySubscriptionId),
|
||||||
@ -405,6 +407,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 50 seats currently assigned with a seat minimum of 100
|
// 50 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -427,11 +446,9 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
||||||
|
|
||||||
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
|
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
|
||||||
await sutProvider.GetDependency<IPaymentService>().DidNotReceiveWithAnyArgs().AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(
|
||||||
Arg.Any<Provider>(),
|
Arg.Any<string>(),
|
||||||
Arg.Any<Bit.Core.Models.StaticStore.Plan>(),
|
Arg.Any<SubscriptionUpdateOptions>());
|
||||||
Arg.Any<int>(),
|
|
||||||
Arg.Any<int>());
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
pPlan => pPlan.AllocatedSeats == 60));
|
pPlan => pPlan.AllocatedSeats == 60));
|
||||||
@ -474,6 +491,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 95 seats currently assigned with a seat minimum of 100
|
// 95 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -496,11 +530,12 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
||||||
|
|
||||||
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
|
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
|
||||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
|
||||||
provider,
|
provider.GatewaySubscriptionId,
|
||||||
StaticStore.GetPlan(providerPlan.PlanType),
|
Arg.Is<SubscriptionUpdateOptions>(
|
||||||
providerPlan.SeatMinimum!.Value,
|
options =>
|
||||||
105);
|
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
options.Items.First().Quantity == 105));
|
||||||
|
|
||||||
// 105 total seats - 100 minimum = 5 purchased seats
|
// 105 total seats - 100 minimum = 5 purchased seats
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
@ -544,6 +579,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 110 seats currently assigned with a seat minimum of 100
|
// 110 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -566,11 +618,12 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
||||||
|
|
||||||
// 110 current + 10 seat scale up = 120 seats
|
// 110 current + 10 seat scale up = 120 seats
|
||||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
|
||||||
provider,
|
provider.GatewaySubscriptionId,
|
||||||
StaticStore.GetPlan(providerPlan.PlanType),
|
Arg.Is<SubscriptionUpdateOptions>(
|
||||||
110,
|
options =>
|
||||||
120);
|
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
options.Items.First().Quantity == 120));
|
||||||
|
|
||||||
// 120 total seats - 100 seat minimum = 20 purchased seats
|
// 120 total seats - 100 seat minimum = 20 purchased seats
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
@ -614,6 +667,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 110 seats currently assigned with a seat minimum of 100
|
// 110 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -636,11 +706,12 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
|
||||||
|
|
||||||
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
|
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
|
||||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
|
||||||
provider,
|
provider.GatewaySubscriptionId,
|
||||||
StaticStore.GetPlan(providerPlan.PlanType),
|
Arg.Is<SubscriptionUpdateOptions>(
|
||||||
110,
|
options =>
|
||||||
providerPlan.SeatMinimum!.Value);
|
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
options.Items.First().Quantity == providerPlan.SeatMinimum!.Value));
|
||||||
|
|
||||||
// Being below the seat minimum means no purchased seats.
|
// Being below the seat minimum means no purchased seats.
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
@ -924,11 +995,15 @@ public class ProviderBillingServiceTests
|
|||||||
{
|
{
|
||||||
provider.GatewaySubscriptionId = null;
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
{
|
.GetCustomerOrThrow(
|
||||||
Id = "customer_id",
|
provider,
|
||||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
|
||||||
});
|
.Returns(new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
});
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -973,13 +1048,18 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider,
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
Provider provider)
|
Provider provider)
|
||||||
{
|
{
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
provider.GatewaySubscriptionId = null;
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer
|
var customer = new Customer
|
||||||
{
|
{
|
||||||
Id = "customer_id",
|
Id = "customer_id",
|
||||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
});
|
};
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetCustomerOrThrow(
|
||||||
|
provider,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1012,11 +1092,21 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
.Returns(providerPlans);
|
.Returns(providerPlans);
|
||||||
|
|
||||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
|
||||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
|
||||||
|
|
||||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||||
|
.When(x => x.SetCreateOptions(
|
||||||
|
Arg.Is<SubscriptionCreateOptions>(options =>
|
||||||
|
options.Customer == "customer_id")
|
||||||
|
, Arg.Is<Customer>(p => p == customer)))
|
||||||
|
.Do(x =>
|
||||||
|
{
|
||||||
|
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||||
sub =>
|
sub =>
|
||||||
sub.AutomaticTax.Enabled == true &&
|
sub.AutomaticTax.Enabled == true &&
|
||||||
@ -1024,9 +1114,9 @@ public class ProviderBillingServiceTests
|
|||||||
sub.Customer == "customer_id" &&
|
sub.Customer == "customer_id" &&
|
||||||
sub.DaysUntilDue == 30 &&
|
sub.DaysUntilDue == 30 &&
|
||||||
sub.Items.Count == 2 &&
|
sub.Items.Count == 2 &&
|
||||||
sub.Items.ElementAt(0).Price == teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId &&
|
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
sub.Items.ElementAt(0).Quantity == 100 &&
|
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||||
sub.Items.ElementAt(1).Price == enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId &&
|
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||||
sub.Items.ElementAt(1).Quantity == 100 &&
|
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||||
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||||
sub.OffSession == true &&
|
sub.OffSession == true &&
|
||||||
@ -1048,8 +1138,7 @@ public class ProviderBillingServiceTests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.TeamsMonthly, -10),
|
(PlanType.TeamsMonthly, -10),
|
||||||
(PlanType.EnterpriseMonthly, 50)
|
(PlanType.EnterpriseMonthly, 50)
|
||||||
@ -1068,6 +1157,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1097,9 +1188,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
provider.Id).Returns(subscription);
|
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1116,8 +1205,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 30),
|
(PlanType.EnterpriseMonthly, 30),
|
||||||
(PlanType.TeamsMonthly, 20)
|
(PlanType.TeamsMonthly, 20)
|
||||||
@ -1149,6 +1237,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1178,7 +1268,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1195,8 +1285,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 70),
|
(PlanType.EnterpriseMonthly, 70),
|
||||||
(PlanType.TeamsMonthly, 50)
|
(PlanType.TeamsMonthly, 50)
|
||||||
@ -1228,6 +1317,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1257,7 +1348,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1274,8 +1365,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 60),
|
(PlanType.EnterpriseMonthly, 60),
|
||||||
(PlanType.TeamsMonthly, 60)
|
(PlanType.TeamsMonthly, 60)
|
||||||
@ -1301,6 +1391,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1330,7 +1422,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1347,8 +1439,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 80),
|
(PlanType.EnterpriseMonthly, 80),
|
||||||
(PlanType.TeamsMonthly, 80)
|
(PlanType.TeamsMonthly, 80)
|
||||||
@ -1380,6 +1471,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1409,7 +1502,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1426,8 +1519,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 70),
|
(PlanType.EnterpriseMonthly, 70),
|
||||||
(PlanType.TeamsMonthly, 30)
|
(PlanType.TeamsMonthly, 30)
|
||||||
|
@ -0,0 +1,151 @@
|
|||||||
|
using Bit.Commercial.Core.Billing;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Stripe;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Commercial.Core.Test.Billing;
|
||||||
|
|
||||||
|
public class ProviderPriceAdapterTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)]
|
||||||
|
[InlineData("password-manager-provider-portal-teams-monthly-2024", PlanType.TeamsMonthly)]
|
||||||
|
public void GetPriceId_MSP_Legacy_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.Msp
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||||
|
[InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)]
|
||||||
|
public void GetPriceId_MSP_Active_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.Msp
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("password-manager-provider-portal-enterprise-annually-2024", PlanType.EnterpriseAnnually)]
|
||||||
|
[InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)]
|
||||||
|
public void GetPriceId_BusinessUnit_Legacy_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.MultiOrganizationEnterprise
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)]
|
||||||
|
[InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||||
|
public void GetPriceId_BusinessUnit_Active_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.MultiOrganizationEnterprise
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||||
|
[InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)]
|
||||||
|
public void GetActivePriceId_MSP_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.Msp
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)]
|
||||||
|
[InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||||
|
public void GetActivePriceId_BusinessUnit_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.MultiOrganizationEnterprise
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
}
|
@ -300,8 +300,7 @@ public class ProvidersController : Controller
|
|||||||
{
|
{
|
||||||
case ProviderType.Msp:
|
case ProviderType.Msp:
|
||||||
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
|
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
|
||||||
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
|
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
|
||||||
@ -314,15 +313,14 @@ public class ProvidersController : Controller
|
|||||||
|
|
||||||
// 1. Change the plan and take over any old values.
|
// 1. Change the plan and take over any old values.
|
||||||
var changeMoePlanCommand = new ChangeProviderPlanCommand(
|
var changeMoePlanCommand = new ChangeProviderPlanCommand(
|
||||||
|
provider,
|
||||||
existingMoePlan.Id,
|
existingMoePlan.Id,
|
||||||
model.Plan!.Value,
|
model.Plan!.Value);
|
||||||
provider.GatewaySubscriptionId);
|
|
||||||
await _providerBillingService.ChangePlan(changeMoePlanCommand);
|
await _providerBillingService.ChangePlan(changeMoePlanCommand);
|
||||||
|
|
||||||
// 2. Update the seat minimums.
|
// 2. Update the seat minimums.
|
||||||
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
|
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
|
||||||
]);
|
]);
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Bit.Api.Auth.Models.Request;
|
using Bit.Api.Auth.Models.Request;
|
||||||
using Bit.Api.Auth.Models.Request.Accounts;
|
|
||||||
using Bit.Api.Models.Request;
|
using Bit.Api.Models.Request;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Core.Auth.Models.Api.Request;
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
@ -125,7 +124,7 @@ public class DevicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{identifier}/retrieve-keys")]
|
[HttpPost("{identifier}/retrieve-keys")]
|
||||||
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier, [FromBody] SecretVerificationRequestModel model)
|
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
|
||||||
{
|
{
|
||||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
|
||||||
@ -134,14 +133,7 @@ public class DevicesController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _userService.VerifySecretAsync(user, model.Secret))
|
|
||||||
{
|
|
||||||
await Task.Delay(2000);
|
|
||||||
throw new BadRequestException(string.Empty, "User verification failed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id);
|
var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id);
|
||||||
|
|
||||||
if (device == null)
|
if (device == null)
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
|
@ -8,6 +8,7 @@ using Bit.Api.Tools.Models.Request;
|
|||||||
using Bit.Api.Vault.Models.Request;
|
using Bit.Api.Vault.Models.Request;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.Auth.Entities;
|
using Bit.Core.Auth.Entities;
|
||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
@ -43,6 +44,7 @@ public class AccountsKeyManagementController : Controller
|
|||||||
_organizationUserValidator;
|
_organizationUserValidator;
|
||||||
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
||||||
_webauthnKeyValidator;
|
_webauthnKeyValidator;
|
||||||
|
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
|
||||||
|
|
||||||
public AccountsKeyManagementController(IUserService userService,
|
public AccountsKeyManagementController(IUserService userService,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
@ -57,7 +59,8 @@ public class AccountsKeyManagementController : Controller
|
|||||||
emergencyAccessValidator,
|
emergencyAccessValidator,
|
||||||
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
||||||
organizationUserValidator,
|
organizationUserValidator,
|
||||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator)
|
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
|
||||||
|
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
|
||||||
{
|
{
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
@ -71,6 +74,7 @@ public class AccountsKeyManagementController : Controller
|
|||||||
_emergencyAccessValidator = emergencyAccessValidator;
|
_emergencyAccessValidator = emergencyAccessValidator;
|
||||||
_organizationUserValidator = organizationUserValidator;
|
_organizationUserValidator = organizationUserValidator;
|
||||||
_webauthnKeyValidator = webAuthnKeyValidator;
|
_webauthnKeyValidator = webAuthnKeyValidator;
|
||||||
|
_deviceValidator = deviceValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("regenerate-keys")]
|
[HttpPost("regenerate-keys")]
|
||||||
@ -109,6 +113,7 @@ public class AccountsKeyManagementController : Controller
|
|||||||
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
|
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
|
||||||
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
|
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
|
||||||
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
|
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
|
||||||
|
DeviceKeys = await _deviceValidator.ValidateAsync(user, model.AccountUnlockData.DeviceKeyUnlockData),
|
||||||
|
|
||||||
Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
|
Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
|
||||||
Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),
|
Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),
|
||||||
|
@ -3,6 +3,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations;
|
|||||||
using Bit.Api.Auth.Models.Request;
|
using Bit.Api.Auth.Models.Request;
|
||||||
using Bit.Api.Auth.Models.Request.Accounts;
|
using Bit.Api.Auth.Models.Request.Accounts;
|
||||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
|
|
||||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||||
|
|
||||||
@ -13,4 +14,5 @@ public class UnlockDataRequestModel
|
|||||||
public required IEnumerable<EmergencyAccessWithIdRequestModel> EmergencyAccessUnlockData { get; set; }
|
public required IEnumerable<EmergencyAccessWithIdRequestModel> EmergencyAccessUnlockData { get; set; }
|
||||||
public required IEnumerable<ResetPasswordWithOrgIdRequestModel> OrganizationAccountRecoveryUnlockData { get; set; }
|
public required IEnumerable<ResetPasswordWithOrgIdRequestModel> OrganizationAccountRecoveryUnlockData { get; set; }
|
||||||
public required IEnumerable<WebAuthnLoginRotateKeyRequestModel> PasskeyUnlockData { get; set; }
|
public required IEnumerable<WebAuthnLoginRotateKeyRequestModel> PasskeyUnlockData { get; set; }
|
||||||
|
public required IEnumerable<OtherDeviceKeysUpdateRequestModel> DeviceKeyUnlockData { get; set; }
|
||||||
}
|
}
|
||||||
|
53
src/Api/KeyManagement/Validators/DeviceRotationValidator.cs
Normal file
53
src/Api/KeyManagement/Validators/DeviceRotationValidator.cs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
|
using Bit.Core.Auth.Utilities;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
|
||||||
|
namespace Bit.Api.KeyManagement.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Device implementation for <see cref="IRotationValidator{T,R}"/>
|
||||||
|
/// </summary>
|
||||||
|
public class DeviceRotationValidator : IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>>
|
||||||
|
{
|
||||||
|
private readonly IDeviceRepository _deviceRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates a new <see cref="DeviceRotationValidator"/>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="deviceRepository">Retrieves all user <see cref="Device"/>s</param>
|
||||||
|
public DeviceRotationValidator(IDeviceRepository deviceRepository)
|
||||||
|
{
|
||||||
|
_deviceRepository = deviceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Device>> ValidateAsync(User user, IEnumerable<OtherDeviceKeysUpdateRequestModel> devices)
|
||||||
|
{
|
||||||
|
var result = new List<Device>();
|
||||||
|
|
||||||
|
var existingTrustedDevices = (await _deviceRepository.GetManyByUserIdAsync(user.Id)).Where(d => d.IsTrusted()).ToList();
|
||||||
|
if (existingTrustedDevices.Count == 0)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var existing in existingTrustedDevices)
|
||||||
|
{
|
||||||
|
var device = devices.FirstOrDefault(c => c.DeviceId == existing.Id);
|
||||||
|
if (device == null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("All existing trusted devices must be included in the rotation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.EncryptedUserKey == null || device.EncryptedPublicKey == null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Rotated encryption keys must be provided for all devices that are trusted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(device.ToDevice(existing));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
@ -17,20 +17,20 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator<IEnumerable<
|
|||||||
|
|
||||||
public async Task<IEnumerable<WebAuthnLoginRotateKeyData>> ValidateAsync(User user, IEnumerable<WebAuthnLoginRotateKeyRequestModel> keysToRotate)
|
public async Task<IEnumerable<WebAuthnLoginRotateKeyData>> ValidateAsync(User user, IEnumerable<WebAuthnLoginRotateKeyRequestModel> keysToRotate)
|
||||||
{
|
{
|
||||||
// 2024-06: Remove after 3 releases, for backward compatibility
|
|
||||||
if (keysToRotate == null)
|
|
||||||
{
|
|
||||||
return new List<WebAuthnLoginRotateKeyData>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new List<WebAuthnLoginRotateKeyData>();
|
var result = new List<WebAuthnLoginRotateKeyData>();
|
||||||
var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
|
var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
|
||||||
if (existing == null || !existing.Any())
|
if (existing == null)
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var ea in existing)
|
var validCredentials = existing.Where(credential => credential.SupportsPrf);
|
||||||
|
if (!validCredentials.Any())
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var ea in validCredentials)
|
||||||
{
|
{
|
||||||
var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id);
|
var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id);
|
||||||
if (keyToRotate == null)
|
if (keyToRotate == null)
|
||||||
|
@ -22,6 +22,7 @@ public class NotificationResponseModel : ResponseModel
|
|||||||
Title = notificationStatusDetails.Title;
|
Title = notificationStatusDetails.Title;
|
||||||
Body = notificationStatusDetails.Body;
|
Body = notificationStatusDetails.Body;
|
||||||
Date = notificationStatusDetails.RevisionDate;
|
Date = notificationStatusDetails.RevisionDate;
|
||||||
|
TaskId = notificationStatusDetails.TaskId;
|
||||||
ReadDate = notificationStatusDetails.ReadDate;
|
ReadDate = notificationStatusDetails.ReadDate;
|
||||||
DeletedDate = notificationStatusDetails.DeletedDate;
|
DeletedDate = notificationStatusDetails.DeletedDate;
|
||||||
}
|
}
|
||||||
@ -40,6 +41,8 @@ public class NotificationResponseModel : ResponseModel
|
|||||||
|
|
||||||
public DateTime Date { get; set; }
|
public DateTime Date { get; set; }
|
||||||
|
|
||||||
|
public Guid? TaskId { get; set; }
|
||||||
|
|
||||||
public DateTime? ReadDate { get; set; }
|
public DateTime? ReadDate { get; set; }
|
||||||
|
|
||||||
public DateTime? DeletedDate { get; set; }
|
public DateTime? DeletedDate { get; set; }
|
||||||
|
@ -31,7 +31,7 @@ using Bit.Core.Auth.Models.Data;
|
|||||||
using Bit.Core.Auth.Identity.TokenProviders;
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
using Bit.Core.Tools.ImportFeatures;
|
using Bit.Core.Tools.ImportFeatures;
|
||||||
using Bit.Core.Tools.ReportFeatures;
|
using Bit.Core.Tools.ReportFeatures;
|
||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
|
|
||||||
#if !OSS
|
#if !OSS
|
||||||
using Bit.Commercial.Core.SecretsManager;
|
using Bit.Commercial.Core.SecretsManager;
|
||||||
@ -168,6 +168,9 @@ public class Startup
|
|||||||
services
|
services
|
||||||
.AddScoped<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>,
|
.AddScoped<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>,
|
||||||
WebAuthnLoginKeyRotationValidator>();
|
WebAuthnLoginKeyRotationValidator>();
|
||||||
|
services
|
||||||
|
.AddScoped<IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>>,
|
||||||
|
DeviceRotationValidator>();
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
services.AddBaseServices(globalSettings);
|
services.AddBaseServices(globalSettings);
|
||||||
|
@ -16,6 +16,7 @@ using Bit.Core.Services;
|
|||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Tools.Services;
|
using Bit.Core.Tools.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using Bit.Core.Vault.Authorization.Permissions;
|
||||||
using Bit.Core.Vault.Entities;
|
using Bit.Core.Vault.Entities;
|
||||||
using Bit.Core.Vault.Models.Data;
|
using Bit.Core.Vault.Models.Data;
|
||||||
using Bit.Core.Vault.Queries;
|
using Bit.Core.Vault.Queries;
|
||||||
@ -345,6 +346,77 @@ public class CiphersController : Controller
|
|||||||
return await CanEditCiphersAsync(organizationId, cipherIds);
|
return await CanEditCiphersAsync(organizationId, cipherIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanDeleteOrRestoreCipherAsAdminAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
|
||||||
|
{
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
|
||||||
|
{
|
||||||
|
return await CanEditCipherAsAdminAsync(organizationId, cipherIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
var org = _currentContext.GetOrganization(organizationId);
|
||||||
|
|
||||||
|
// If we're not an "admin", we don't need to check the ciphers
|
||||||
|
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }))
|
||||||
|
{
|
||||||
|
// Are we a provider user? If so, we need to be sure we're not restricted
|
||||||
|
// Once the feature flag is removed, this check can be combined with the above
|
||||||
|
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
|
||||||
|
{
|
||||||
|
// Provider is restricted from editing ciphers, so we're not an "admin"
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is unrestricted, so we're an "admin", don't return early
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Not a provider or admin
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user can edit all ciphers for the organization, just check they all belong to the org
|
||||||
|
if (await CanEditAllCiphersAsync(organizationId))
|
||||||
|
{
|
||||||
|
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
|
||||||
|
var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
|
||||||
|
|
||||||
|
// Ensure all requested ciphers are in orgCiphers
|
||||||
|
return cipherIds.All(c => orgCiphers.ContainsKey(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user cannot access any ciphers for the organization, we're done
|
||||||
|
if (!await CanAccessOrganizationCiphersAsync(organizationId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
// Select all deletable ciphers for this user belonging to the organization
|
||||||
|
var deletableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(user.Id, true))
|
||||||
|
.Where(c => c.OrganizationId == organizationId && c.UserId == null).ToList();
|
||||||
|
|
||||||
|
// Special case for unassigned ciphers
|
||||||
|
if (await CanAccessUnassignedCiphersAsync(organizationId))
|
||||||
|
{
|
||||||
|
var unassignedCiphers =
|
||||||
|
(await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(
|
||||||
|
organizationId));
|
||||||
|
|
||||||
|
// Users that can access unassigned ciphers can also delete them
|
||||||
|
deletableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Manage = true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
|
||||||
|
var deletableOrgCiphers = deletableOrgCipherList
|
||||||
|
.Where(c => NormalCipherPermissions.CanDelete(user, c, organizationAbility))
|
||||||
|
.ToDictionary(c => c.Id);
|
||||||
|
|
||||||
|
return cipherIds.All(c => deletableOrgCiphers.ContainsKey(c));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -763,12 +835,12 @@ public class CiphersController : Controller
|
|||||||
|
|
||||||
[HttpDelete("{id}/admin")]
|
[HttpDelete("{id}/admin")]
|
||||||
[HttpPost("{id}/delete-admin")]
|
[HttpPost("{id}/delete-admin")]
|
||||||
public async Task DeleteAdmin(string id)
|
public async Task DeleteAdmin(Guid id)
|
||||||
{
|
{
|
||||||
var userId = _userService.GetProperUserId(User).Value;
|
var userId = _userService.GetProperUserId(User).Value;
|
||||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
|
var cipher = await GetByIdAsync(id, userId);
|
||||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
@ -808,7 +880,7 @@ public class CiphersController : Controller
|
|||||||
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
|
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
|
if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
|
||||||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
|
!await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
@ -830,12 +902,12 @@ public class CiphersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}/delete-admin")]
|
[HttpPut("{id}/delete-admin")]
|
||||||
public async Task PutDeleteAdmin(string id)
|
public async Task PutDeleteAdmin(Guid id)
|
||||||
{
|
{
|
||||||
var userId = _userService.GetProperUserId(User).Value;
|
var userId = _userService.GetProperUserId(User).Value;
|
||||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
|
var cipher = await GetByIdAsync(id, userId);
|
||||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
@ -871,7 +943,7 @@ public class CiphersController : Controller
|
|||||||
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
|
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
|
if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
|
||||||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
|
!await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
@ -899,12 +971,12 @@ public class CiphersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}/restore-admin")]
|
[HttpPut("{id}/restore-admin")]
|
||||||
public async Task<CipherMiniResponseModel> PutRestoreAdmin(string id)
|
public async Task<CipherMiniResponseModel> PutRestoreAdmin(Guid id)
|
||||||
{
|
{
|
||||||
var userId = _userService.GetProperUserId(User).Value;
|
var userId = _userService.GetProperUserId(User).Value;
|
||||||
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
|
var cipher = await GetByIdAsync(id, userId);
|
||||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
@ -944,7 +1016,7 @@ public class CiphersController : Controller
|
|||||||
|
|
||||||
var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i)));
|
var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i)));
|
||||||
|
|
||||||
if (model.OrganizationId == default || !await CanEditCipherAsAdminAsync(model.OrganizationId, cipherIdsToRestore))
|
if (model.OrganizationId == default || !await CanDeleteOrRestoreCipherAsAdminAsync(model.OrganizationId, cipherIdsToRestore))
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
@ -12,6 +15,7 @@ using Event = Stripe.Event;
|
|||||||
namespace Bit.Billing.Services.Implementations;
|
namespace Bit.Billing.Services.Implementations;
|
||||||
|
|
||||||
public class UpcomingInvoiceHandler(
|
public class UpcomingInvoiceHandler(
|
||||||
|
IFeatureService featureService,
|
||||||
ILogger<StripeEventProcessor> logger,
|
ILogger<StripeEventProcessor> logger,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -21,7 +25,8 @@ public class UpcomingInvoiceHandler(
|
|||||||
IStripeEventService stripeEventService,
|
IStripeEventService stripeEventService,
|
||||||
IStripeEventUtilityService stripeEventUtilityService,
|
IStripeEventUtilityService stripeEventUtilityService,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IValidateSponsorshipCommand validateSponsorshipCommand)
|
IValidateSponsorshipCommand validateSponsorshipCommand,
|
||||||
|
IAutomaticTaxFactory automaticTaxFactory)
|
||||||
: IUpcomingInvoiceHandler
|
: IUpcomingInvoiceHandler
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Event parsedEvent)
|
public async Task HandleAsync(Event parsedEvent)
|
||||||
@ -136,6 +141,21 @@ public class UpcomingInvoiceHandler(
|
|||||||
|
|
||||||
private async Task TryEnableAutomaticTaxAsync(Subscription subscription)
|
private async Task TryEnableAutomaticTaxAsync(Subscription subscription)
|
||||||
{
|
{
|
||||||
|
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
|
{
|
||||||
|
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscription.Items.Select(x => x.Price.Id));
|
||||||
|
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||||
|
var updateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
if (updateOptions == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (subscription.AutomaticTax.Enabled ||
|
if (subscription.AutomaticTax.Enabled ||
|
||||||
!subscription.Customer.HasBillingLocation() ||
|
!subscription.Customer.HasBillingLocation() ||
|
||||||
await IsNonTaxableNonUSBusinessUseSubscription(subscription))
|
await IsNonTaxableNonUSBusinessUseSubscription(subscription))
|
||||||
|
@ -313,5 +313,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
|||||||
UseSecretsManager = license.UseSecretsManager;
|
UseSecretsManager = license.UseSecretsManager;
|
||||||
SmSeats = license.SmSeats;
|
SmSeats = license.SmSeats;
|
||||||
SmServiceAccounts = license.SmServiceAccounts;
|
SmServiceAccounts = license.SmServiceAccounts;
|
||||||
|
UseRiskInsights = license.UseRiskInsights;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,64 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Bit.Core.Models.Data.Organizations;
|
||||||
|
|
||||||
|
public class OrganizationIntegrationConfigurationDetails
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid OrganizationIntegrationId { get; set; }
|
||||||
|
public IntegrationType IntegrationType { get; set; }
|
||||||
|
public EventType EventType { get; set; }
|
||||||
|
public string? Configuration { get; set; }
|
||||||
|
public string? IntegrationConfiguration { get; set; }
|
||||||
|
public string? Template { get; set; }
|
||||||
|
|
||||||
|
public JsonObject MergedConfiguration
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var integrationJson = IntegrationConfigurationJson;
|
||||||
|
|
||||||
|
foreach (var kvp in ConfigurationJson)
|
||||||
|
{
|
||||||
|
integrationJson[kvp.Key] = kvp.Value?.DeepClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
return integrationJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject ConfigurationJson
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configuration = Configuration ?? string.Empty;
|
||||||
|
return JsonNode.Parse(configuration) as JsonObject ?? new JsonObject();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject IntegrationConfigurationJson
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var integration = IntegrationConfiguration ?? string.Empty;
|
||||||
|
return JsonNode.Parse(integration) as JsonObject ?? new JsonObject();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -148,7 +148,8 @@ public class SelfHostedOrganizationDetails : Organization
|
|||||||
LimitCollectionDeletion = LimitCollectionDeletion,
|
LimitCollectionDeletion = LimitCollectionDeletion,
|
||||||
LimitItemDeletion = LimitItemDeletion,
|
LimitItemDeletion = LimitItemDeletion,
|
||||||
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
|
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
|
||||||
Status = Status
|
Status = Status,
|
||||||
|
UseRiskInsights = UseRiskInsights,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -154,6 +154,12 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Custom users can not delete admins.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged)
|
if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged)
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Member is not managed by the organization.");
|
throw new BadRequestException("Member is not managed by the organization.");
|
||||||
|
@ -25,7 +25,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
public const string UserNotFoundErrorMessage = "User not found.";
|
public const string UserNotFoundErrorMessage = "User not found.";
|
||||||
public const string UsersInvalidErrorMessage = "Users invalid.";
|
public const string UsersInvalidErrorMessage = "Users invalid.";
|
||||||
public const string RemoveYourselfErrorMessage = "You cannot remove yourself.";
|
public const string RemoveYourselfErrorMessage = "You cannot remove yourself.";
|
||||||
public const string RemoveOwnerByNonOwnerErrorMessage = "Only owners can delete other owners.";
|
public const string RemoveOwnerByNonOwnerErrorMessage = "Only owners can remove other owners.";
|
||||||
|
public const string RemoveAdminByCustomUserErrorMessage = "Custom users can not remove admins.";
|
||||||
public const string RemoveLastConfirmedOwnerErrorMessage = "Organization must have at least one confirmed owner.";
|
public const string RemoveLastConfirmedOwnerErrorMessage = "Organization must have at least one confirmed owner.";
|
||||||
public const string RemoveClaimedAccountErrorMessage = "Cannot remove member accounts claimed by the organization. To offboard a member, revoke or delete the account.";
|
public const string RemoveClaimedAccountErrorMessage = "Cannot remove member accounts claimed by the organization. To offboard a member, revoke or delete the account.";
|
||||||
|
|
||||||
@ -153,6 +154,11 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(orgUser.OrganizationId))
|
||||||
|
{
|
||||||
|
throw new BadRequestException(RemoveAdminByCustomUserErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null)
|
||||||
{
|
{
|
||||||
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id });
|
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id });
|
||||||
|
@ -87,7 +87,10 @@ public class RestoreOrganizationUserCommand(
|
|||||||
.twoFactorIsEnabled;
|
.twoFactorIsEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
|
if (organization.PlanType == PlanType.Free)
|
||||||
|
{
|
||||||
|
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
|
||||||
|
}
|
||||||
|
|
||||||
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
|
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
|
||||||
|
|
||||||
@ -100,7 +103,7 @@ public class RestoreOrganizationUserCommand(
|
|||||||
|
|
||||||
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
|
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
|
||||||
{
|
{
|
||||||
var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId.Value);
|
var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId!.Value);
|
||||||
var otherOrgs = await organizationRepository.GetManyByUserIdAsync(organizationUser.UserId.Value);
|
var otherOrgs = await organizationRepository.GetManyByUserIdAsync(organizationUser.UserId.Value);
|
||||||
|
|
||||||
var orgOrgUserDict = relatedOrgUsersFromOtherOrgs
|
var orgOrgUserDict = relatedOrgUsersFromOtherOrgs
|
||||||
@ -110,13 +113,16 @@ public class RestoreOrganizationUserCommand(
|
|||||||
CheckForOtherFreeOrganizationOwnership(organizationUser, orgOrgUserDict);
|
CheckForOtherFreeOrganizationOwnership(organizationUser, orgOrgUserDict);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Dictionary<OrganizationUser, Organization>> GetRelatedOrganizationUsersAndOrganizations(
|
private async Task<Dictionary<OrganizationUser, Organization>> GetRelatedOrganizationUsersAndOrganizationsAsync(
|
||||||
IEnumerable<OrganizationUser> organizationUsers)
|
List<OrganizationUser> organizationUsers)
|
||||||
{
|
{
|
||||||
var allUserIds = organizationUsers.Select(x => x.UserId.Value);
|
var allUserIds = organizationUsers
|
||||||
|
.Where(x => x.UserId.HasValue)
|
||||||
|
.Select(x => x.UserId.Value);
|
||||||
|
|
||||||
var otherOrganizationUsers = (await organizationUserRepository.GetManyByManyUsersAsync(allUserIds))
|
var otherOrganizationUsers = (await organizationUserRepository.GetManyByManyUsersAsync(allUserIds))
|
||||||
.Where(x => organizationUsers.Any(y => y.Id == x.Id) == false);
|
.Where(x => organizationUsers.Any(y => y.Id == x.Id) == false)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var otherOrgs = await organizationRepository.GetManyByIdsAsync(otherOrganizationUsers
|
var otherOrgs = await organizationRepository.GetManyByIdsAsync(otherOrganizationUsers
|
||||||
.Select(x => x.OrganizationId)
|
.Select(x => x.OrganizationId)
|
||||||
@ -130,7 +136,9 @@ public class RestoreOrganizationUserCommand(
|
|||||||
Dictionary<OrganizationUser, Organization> otherOrgUsersAndOrgs)
|
Dictionary<OrganizationUser, Organization> otherOrgUsersAndOrgs)
|
||||||
{
|
{
|
||||||
var ownerOrAdminList = new[] { OrganizationUserType.Owner, OrganizationUserType.Admin };
|
var ownerOrAdminList = new[] { OrganizationUserType.Owner, OrganizationUserType.Admin };
|
||||||
if (otherOrgUsersAndOrgs.Any(x =>
|
|
||||||
|
if (ownerOrAdminList.Any(x => organizationUser.Type == x) &&
|
||||||
|
otherOrgUsersAndOrgs.Any(x =>
|
||||||
x.Key.UserId == organizationUser.UserId &&
|
x.Key.UserId == organizationUser.UserId &&
|
||||||
ownerOrAdminList.Any(userType => userType == x.Key.Type) &&
|
ownerOrAdminList.Any(userType => userType == x.Key.Type) &&
|
||||||
x.Key.Status == OrganizationUserStatusType.Confirmed &&
|
x.Key.Status == OrganizationUserStatusType.Confirmed &&
|
||||||
@ -170,7 +178,7 @@ public class RestoreOrganizationUserCommand(
|
|||||||
var organizationUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
|
var organizationUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
|
||||||
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
|
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
|
||||||
|
|
||||||
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizations(filteredUsers);
|
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers);
|
||||||
|
|
||||||
var result = new List<Tuple<OrganizationUser, string>>();
|
var result = new List<Tuple<OrganizationUser, string>>();
|
||||||
|
|
||||||
@ -201,7 +209,10 @@ public class RestoreOrganizationUserCommand(
|
|||||||
|
|
||||||
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
|
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
|
||||||
|
|
||||||
CheckForOtherFreeOrganizationOwnership(organizationUser, orgUsersAndOrgs);
|
if (organization.PlanType == PlanType.Free)
|
||||||
|
{
|
||||||
|
CheckForOtherFreeOrganizationOwnership(organizationUser, orgUsersAndOrgs);
|
||||||
|
}
|
||||||
|
|
||||||
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
|
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
|
||||||
|
|
||||||
|
@ -34,6 +34,8 @@ public class ResetPasswordPolicyRequirementFactory : BasePolicyRequirementFactor
|
|||||||
|
|
||||||
protected override IEnumerable<OrganizationUserType> ExemptRoles => [];
|
protected override IEnumerable<OrganizationUserType> ExemptRoles => [];
|
||||||
|
|
||||||
|
protected override IEnumerable<OrganizationUserStatusType> ExemptStatuses => [OrganizationUserStatusType.Revoked];
|
||||||
|
|
||||||
public override ResetPasswordPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
|
public override ResetPasswordPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
|
||||||
{
|
{
|
||||||
var result = policyDetails
|
var result = policyDetails
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data.Organizations;
|
||||||
|
|
||||||
|
namespace Bit.Core.Repositories;
|
||||||
|
|
||||||
|
public interface IOrganizationIntegrationConfigurationRepository : IRepository<OrganizationIntegrationConfiguration, Guid>
|
||||||
|
{
|
||||||
|
Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
|
||||||
|
Guid organizationId,
|
||||||
|
IntegrationType integrationType,
|
||||||
|
EventType eventType);
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.Repositories;
|
||||||
|
|
||||||
|
public interface IOrganizationIntegrationRepository : IRepository<OrganizationIntegration, Guid>
|
||||||
|
{
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
namespace Bit.Core.Auth.Models.Api.Request;
|
namespace Bit.Core.Auth.Models.Api.Request;
|
||||||
@ -7,6 +8,13 @@ public class OtherDeviceKeysUpdateRequestModel : DeviceKeysUpdateRequestModel
|
|||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
public Guid DeviceId { get; set; }
|
public Guid DeviceId { get; set; }
|
||||||
|
|
||||||
|
public Device ToDevice(Device existingDevice)
|
||||||
|
{
|
||||||
|
existingDevice.EncryptedPublicKey = EncryptedPublicKey;
|
||||||
|
existingDevice.EncryptedUserKey = EncryptedUserKey;
|
||||||
|
return existingDevice;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeviceKeysUpdateRequestModel
|
public class DeviceKeysUpdateRequestModel
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Auth.Utilities;
|
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
|
||||||
@ -19,7 +18,7 @@ public class DeviceAuthRequestResponseModel : ResponseModel
|
|||||||
Type = deviceAuthDetails.Type,
|
Type = deviceAuthDetails.Type,
|
||||||
Identifier = deviceAuthDetails.Identifier,
|
Identifier = deviceAuthDetails.Identifier,
|
||||||
CreationDate = deviceAuthDetails.CreationDate,
|
CreationDate = deviceAuthDetails.CreationDate,
|
||||||
IsTrusted = deviceAuthDetails.IsTrusted()
|
IsTrusted = deviceAuthDetails.IsTrusted,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)
|
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)
|
||||||
|
@ -287,14 +287,14 @@ public class AuthRequestService : IAuthRequestService
|
|||||||
|
|
||||||
private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser organizationUser, User user)
|
private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser organizationUser, User user)
|
||||||
{
|
{
|
||||||
if (!_featureService.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications))
|
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
|
||||||
|
|
||||||
|
if (adminEmails.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Skipped sending device approval notification to admins - feature flag disabled");
|
_logger.LogWarning("There are no admin emails to send to.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
|
|
||||||
|
|
||||||
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(
|
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(
|
||||||
adminEmails,
|
adminEmails,
|
||||||
organizationUser.OrganizationId,
|
organizationUser.OrganizationId,
|
||||||
|
@ -47,6 +47,8 @@ public static class StripeConstants
|
|||||||
public static class MetadataKeys
|
public static class MetadataKeys
|
||||||
{
|
{
|
||||||
public const string OrganizationId = "organizationId";
|
public const string OrganizationId = "organizationId";
|
||||||
|
public const string ProviderId = "providerId";
|
||||||
|
public const string UserId = "userId";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class PaymentBehavior
|
public static class PaymentBehavior
|
||||||
|
@ -21,7 +21,7 @@ public static class CustomerExtensions
|
|||||||
/// <param name="customer"></param>
|
/// <param name="customer"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static bool HasTaxLocationVerified(this Customer customer) =>
|
public static bool HasTaxLocationVerified(this Customer customer) =>
|
||||||
customer?.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
|
customer?.Tax?.AutomaticTax != StripeConstants.AutomaticTaxStatus.UnrecognizedLocation;
|
||||||
|
|
||||||
public static decimal GetBillingBalance(this Customer customer)
|
public static decimal GetBillingBalance(this Customer customer)
|
||||||
{
|
{
|
||||||
|
@ -4,6 +4,7 @@ using Bit.Core.Billing.Licenses.Extensions;
|
|||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Billing.Services.Implementations;
|
using Bit.Core.Billing.Services.Implementations;
|
||||||
|
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Extensions;
|
namespace Bit.Core.Billing.Extensions;
|
||||||
|
|
||||||
@ -18,6 +19,9 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
|
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
|
||||||
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
||||||
services.AddTransient<ISubscriberService, SubscriberService>();
|
services.AddTransient<ISubscriberService, SubscriberService>();
|
||||||
|
services.AddKeyedTransient<IAutomaticTaxStrategy, PersonalUseAutomaticTaxStrategy>(AutomaticTaxFactory.PersonalUse);
|
||||||
|
services.AddKeyedTransient<IAutomaticTaxStrategy, BusinessUseAutomaticTaxStrategy>(AutomaticTaxFactory.BusinessUse);
|
||||||
|
services.AddTransient<IAutomaticTaxFactory, AutomaticTaxFactory>();
|
||||||
services.AddLicenseServices();
|
services.AddLicenseServices();
|
||||||
services.AddPricingClient();
|
services.AddPricingClient();
|
||||||
}
|
}
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
using Stripe;
|
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Extensions;
|
|
||||||
|
|
||||||
public static class SubscriptionCreateOptionsExtensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to enable automatic tax for given new subscription options.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="options"></param>
|
|
||||||
/// <param name="customer">The existing customer.</param>
|
|
||||||
/// <returns>Returns true when successful, false when conditions are not met.</returns>
|
|
||||||
public static bool EnableAutomaticTax(this SubscriptionCreateOptions options, Customer customer)
|
|
||||||
{
|
|
||||||
// We might only need to check the automatic tax status.
|
|
||||||
if (!customer.HasTaxLocationVerified() && string.IsNullOrWhiteSpace(customer.Address?.Country))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
options.DefaultTaxRates = [];
|
|
||||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
@ -36,6 +36,7 @@ public static class OrganizationLicenseConstants
|
|||||||
public const string SmServiceAccounts = nameof(SmServiceAccounts);
|
public const string SmServiceAccounts = nameof(SmServiceAccounts);
|
||||||
public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion);
|
public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion);
|
||||||
public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems);
|
public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems);
|
||||||
|
public const string UseRiskInsights = nameof(UseRiskInsights);
|
||||||
public const string Expires = nameof(Expires);
|
public const string Expires = nameof(Expires);
|
||||||
public const string Refresh = nameof(Refresh);
|
public const string Refresh = nameof(Refresh);
|
||||||
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
|
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
|
||||||
|
@ -47,6 +47,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
|
|||||||
new(nameof(OrganizationLicenseConstants.LimitCollectionCreationDeletion),
|
new(nameof(OrganizationLicenseConstants.LimitCollectionCreationDeletion),
|
||||||
(entity.LimitCollectionCreation || entity.LimitCollectionDeletion).ToString()),
|
(entity.LimitCollectionCreation || entity.LimitCollectionDeletion).ToString()),
|
||||||
new(nameof(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems), entity.AllowAdminAccessToAllCollectionItems.ToString()),
|
new(nameof(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems), entity.AllowAdminAccessToAllCollectionItems.ToString()),
|
||||||
|
new(nameof(OrganizationLicenseConstants.UseRiskInsights), entity.UseRiskInsights.ToString()),
|
||||||
new(nameof(OrganizationLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)),
|
new(nameof(OrganizationLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)),
|
||||||
new(nameof(OrganizationLicenseConstants.Expires), expires.ToString(CultureInfo.InvariantCulture)),
|
new(nameof(OrganizationLicenseConstants.Expires), expires.ToString(CultureInfo.InvariantCulture)),
|
||||||
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
|
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
@ -309,8 +309,7 @@ public class ProviderMigrator(
|
|||||||
.SeatMinimum ?? 0;
|
.SeatMinimum ?? 0;
|
||||||
|
|
||||||
var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum),
|
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum),
|
||||||
(Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum)
|
(Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum)
|
||||||
|
@ -75,6 +75,7 @@ public abstract record Plan
|
|||||||
// Seats
|
// Seats
|
||||||
public string StripePlanId { get; init; }
|
public string StripePlanId { get; init; }
|
||||||
public string StripeSeatPlanId { get; init; }
|
public string StripeSeatPlanId { get; init; }
|
||||||
|
[Obsolete("No longer used to retrieve a provider's price ID. Use ProviderPriceAdapter instead.")]
|
||||||
public string StripeProviderPortalSeatPlanId { get; init; }
|
public string StripeProviderPortalSeatPlanId { get; init; }
|
||||||
public decimal BasePrice { get; init; }
|
public decimal BasePrice { get; init; }
|
||||||
public decimal SeatPrice { get; init; }
|
public decimal SeatPrice { get; init; }
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Services.Contracts;
|
||||||
|
|
||||||
|
public class AutomaticTaxFactoryParameters
|
||||||
|
{
|
||||||
|
public AutomaticTaxFactoryParameters(PlanType planType)
|
||||||
|
{
|
||||||
|
PlanType = planType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AutomaticTaxFactoryParameters(ISubscriber subscriber, IEnumerable<string> prices)
|
||||||
|
{
|
||||||
|
Subscriber = subscriber;
|
||||||
|
Prices = prices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AutomaticTaxFactoryParameters(IEnumerable<string> prices)
|
||||||
|
{
|
||||||
|
Prices = prices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ISubscriber? Subscriber { get; init; }
|
||||||
|
|
||||||
|
public PlanType? PlanType { get; init; }
|
||||||
|
|
||||||
|
public IEnumerable<string>? Prices { get; init; }
|
||||||
|
}
|
@ -1,8 +1,9 @@
|
|||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Services.Contracts;
|
namespace Bit.Core.Billing.Services.Contracts;
|
||||||
|
|
||||||
public record ChangeProviderPlanCommand(
|
public record ChangeProviderPlanCommand(
|
||||||
|
Provider Provider,
|
||||||
Guid ProviderPlanId,
|
Guid ProviderPlanId,
|
||||||
PlanType NewPlan,
|
PlanType NewPlan);
|
||||||
string GatewaySubscriptionId);
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Services.Contracts;
|
namespace Bit.Core.Billing.Services.Contracts;
|
||||||
|
|
||||||
/// <param name="Id">The ID of the provider to update the seat minimums for.</param>
|
/// <param name="Provider">The provider to update the seat minimums for.</param>
|
||||||
/// <param name="Configuration">The new seat minimums for the provider.</param>
|
/// <param name="Configuration">The new seat minimums for the provider.</param>
|
||||||
public record UpdateProviderSeatMinimumsCommand(
|
public record UpdateProviderSeatMinimumsCommand(
|
||||||
Guid Id,
|
Provider Provider,
|
||||||
string GatewaySubscriptionId,
|
|
||||||
IReadOnlyCollection<(PlanType Plan, int SeatsMinimum)> Configuration);
|
IReadOnlyCollection<(PlanType Plan, int SeatsMinimum)> Configuration);
|
||||||
|
11
src/Core/Billing/Services/IAutomaticTaxFactory.cs
Normal file
11
src/Core/Billing/Services/IAutomaticTaxFactory.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Responsible for defining the correct automatic tax strategy for either personal use of business use.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAutomaticTaxFactory
|
||||||
|
{
|
||||||
|
Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters);
|
||||||
|
}
|
33
src/Core/Billing/Services/IAutomaticTaxStrategy.cs
Normal file
33
src/Core/Billing/Services/IAutomaticTaxStrategy.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Services;
|
||||||
|
|
||||||
|
public interface IAutomaticTaxStrategy
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subscription"></param>
|
||||||
|
/// <returns>
|
||||||
|
/// Returns <see cref="SubscriptionUpdateOptions" /> if changes are to be applied to the subscription, returns null
|
||||||
|
/// otherwise.
|
||||||
|
/// </returns>
|
||||||
|
SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modifies an existing <see cref="SubscriptionCreateOptions" /> object with the automatic tax flag set correctly.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options"></param>
|
||||||
|
/// <param name="customer"></param>
|
||||||
|
void SetCreateOptions(SubscriptionCreateOptions options, Customer customer);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modifies an existing <see cref="SubscriptionUpdateOptions" /> object with the automatic tax flag set correctly.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options"></param>
|
||||||
|
/// <param name="subscription"></param>
|
||||||
|
void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription);
|
||||||
|
|
||||||
|
void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options);
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
|
|
||||||
|
public class AutomaticTaxFactory(
|
||||||
|
IFeatureService featureService,
|
||||||
|
IPricingClient pricingClient) : IAutomaticTaxFactory
|
||||||
|
{
|
||||||
|
public const string BusinessUse = "business-use";
|
||||||
|
public const string PersonalUse = "personal-use";
|
||||||
|
|
||||||
|
private readonly Lazy<Task<IEnumerable<string>>> _personalUsePlansTask = new(async () =>
|
||||||
|
{
|
||||||
|
var plans = await Task.WhenAll(
|
||||||
|
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019),
|
||||||
|
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually));
|
||||||
|
|
||||||
|
return plans.Select(plan => plan.PasswordManager.StripePlanId);
|
||||||
|
});
|
||||||
|
|
||||||
|
public async Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters)
|
||||||
|
{
|
||||||
|
if (parameters.Subscriber is User)
|
||||||
|
{
|
||||||
|
return new PersonalUseAutomaticTaxStrategy(featureService);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameters.PlanType.HasValue)
|
||||||
|
{
|
||||||
|
var plan = await pricingClient.GetPlanOrThrow(parameters.PlanType.Value);
|
||||||
|
return plan.CanBeUsedByBusiness
|
||||||
|
? new BusinessUseAutomaticTaxStrategy(featureService)
|
||||||
|
: new PersonalUseAutomaticTaxStrategy(featureService);
|
||||||
|
}
|
||||||
|
|
||||||
|
var personalUsePlans = await _personalUsePlansTask.Value;
|
||||||
|
|
||||||
|
if (parameters.Prices != null && parameters.Prices.Any(x => personalUsePlans.Any(y => y == x)))
|
||||||
|
{
|
||||||
|
return new PersonalUseAutomaticTaxStrategy(featureService);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BusinessUseAutomaticTaxStrategy(featureService);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,96 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
|
|
||||||
|
public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
|
||||||
|
{
|
||||||
|
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
|
||||||
|
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = shouldBeEnabled
|
||||||
|
},
|
||||||
|
DefaultTaxRates = []
|
||||||
|
};
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
|
||||||
|
{
|
||||||
|
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = ShouldBeEnabled(customer)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
|
||||||
|
|
||||||
|
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = shouldBeEnabled
|
||||||
|
};
|
||||||
|
options.DefaultTaxRates = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
|
||||||
|
{
|
||||||
|
options.AutomaticTax ??= new InvoiceAutomaticTaxOptions();
|
||||||
|
|
||||||
|
if (options.CustomerDetails.Address.Country == "US")
|
||||||
|
{
|
||||||
|
options.AutomaticTax.Enabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.AutomaticTax.Enabled = options.CustomerDetails.TaxIds != null && options.CustomerDetails.TaxIds.Any();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldBeEnabled(Customer customer)
|
||||||
|
{
|
||||||
|
if (!customer.HasTaxLocationVerified())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer.Address.Country == "US")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer.TaxIds == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(customer.TaxIds), "`customer.tax_ids` must be expanded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return customer.TaxIds.Any();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
|
|
||||||
|
public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
|
||||||
|
{
|
||||||
|
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
|
||||||
|
{
|
||||||
|
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = ShouldBeEnabled(customer)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = ShouldBeEnabled(subscription.Customer)
|
||||||
|
};
|
||||||
|
options.DefaultTaxRates = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.AutomaticTax.Enabled == ShouldBeEnabled(subscription.Customer))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = ShouldBeEnabled(subscription.Customer),
|
||||||
|
},
|
||||||
|
DefaultTaxRates = []
|
||||||
|
};
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
|
||||||
|
{
|
||||||
|
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldBeEnabled(Customer customer)
|
||||||
|
{
|
||||||
|
return customer.HasTaxLocationVerified();
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,11 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.Billing.Caches;
|
using Bit.Core.Billing.Caches;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Sales;
|
using Bit.Core.Billing.Models.Sales;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@ -23,6 +25,7 @@ namespace Bit.Core.Billing.Services.Implementations;
|
|||||||
|
|
||||||
public class OrganizationBillingService(
|
public class OrganizationBillingService(
|
||||||
IBraintreeGateway braintreeGateway,
|
IBraintreeGateway braintreeGateway,
|
||||||
|
IFeatureService featureService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ILogger<OrganizationBillingService> logger,
|
ILogger<OrganizationBillingService> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -30,7 +33,8 @@ public class OrganizationBillingService(
|
|||||||
ISetupIntentCache setupIntentCache,
|
ISetupIntentCache setupIntentCache,
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
ITaxService taxService) : IOrganizationBillingService
|
ITaxService taxService,
|
||||||
|
IAutomaticTaxFactory automaticTaxFactory) : IOrganizationBillingService
|
||||||
{
|
{
|
||||||
public async Task Finalize(OrganizationSale sale)
|
public async Task Finalize(OrganizationSale sale)
|
||||||
{
|
{
|
||||||
@ -143,7 +147,7 @@ public class OrganizationBillingService(
|
|||||||
Coupon = customerSetup.Coupon,
|
Coupon = customerSetup.Coupon,
|
||||||
Description = organization.DisplayBusinessName(),
|
Description = organization.DisplayBusinessName(),
|
||||||
Email = organization.BillingEmail,
|
Email = organization.BillingEmail,
|
||||||
Expand = ["tax"],
|
Expand = ["tax", "tax_ids"],
|
||||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||||
{
|
{
|
||||||
CustomFields = [
|
CustomFields = [
|
||||||
@ -369,21 +373,8 @@ public class OrganizationBillingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var customerHasTaxInfo = customer is
|
|
||||||
{
|
|
||||||
Address:
|
|
||||||
{
|
|
||||||
Country: not null and not "",
|
|
||||||
PostalCode: not null and not ""
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||||
{
|
{
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
|
||||||
{
|
|
||||||
Enabled = customerHasTaxInfo
|
|
||||||
},
|
|
||||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
||||||
Customer = customer.Id,
|
Customer = customer.Id,
|
||||||
Items = subscriptionItemOptionsList,
|
Items = subscriptionItemOptionsList,
|
||||||
@ -395,6 +386,18 @@ public class OrganizationBillingService(
|
|||||||
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
|
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
|
{
|
||||||
|
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriptionSetup.PlanType);
|
||||||
|
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||||
|
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions();
|
||||||
|
subscriptionCreateOptions.AutomaticTax.Enabled = customer.HasBillingLocation();
|
||||||
|
}
|
||||||
|
|
||||||
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Sales;
|
using Bit.Core.Billing.Models.Sales;
|
||||||
|
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
@ -9,6 +10,7 @@ using Bit.Core.Repositories;
|
|||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Braintree;
|
using Braintree;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Customer = Stripe.Customer;
|
using Customer = Stripe.Customer;
|
||||||
@ -20,12 +22,14 @@ using static Utilities;
|
|||||||
|
|
||||||
public class PremiumUserBillingService(
|
public class PremiumUserBillingService(
|
||||||
IBraintreeGateway braintreeGateway,
|
IBraintreeGateway braintreeGateway,
|
||||||
|
IFeatureService featureService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ILogger<PremiumUserBillingService> logger,
|
ILogger<PremiumUserBillingService> logger,
|
||||||
ISetupIntentCache setupIntentCache,
|
ISetupIntentCache setupIntentCache,
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
IUserRepository userRepository) : IPremiumUserBillingService
|
IUserRepository userRepository,
|
||||||
|
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy automaticTaxStrategy) : IPremiumUserBillingService
|
||||||
{
|
{
|
||||||
public async Task Credit(User user, decimal amount)
|
public async Task Credit(User user, decimal amount)
|
||||||
{
|
{
|
||||||
@ -318,10 +322,6 @@ public class PremiumUserBillingService(
|
|||||||
|
|
||||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||||
{
|
{
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
|
||||||
{
|
|
||||||
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
|
|
||||||
},
|
|
||||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
||||||
Customer = customer.Id,
|
Customer = customer.Id,
|
||||||
Items = subscriptionItemOptionsList,
|
Items = subscriptionItemOptionsList,
|
||||||
@ -335,6 +335,18 @@ public class PremiumUserBillingService(
|
|||||||
OffSession = true
|
OffSession = true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
|
{
|
||||||
|
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||||
|
|
||||||
if (usingPayPal)
|
if (usingPayPal)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using Bit.Core.Billing.Caches;
|
using Bit.Core.Billing.Caches;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
@ -20,11 +21,13 @@ namespace Bit.Core.Billing.Services.Implementations;
|
|||||||
|
|
||||||
public class SubscriberService(
|
public class SubscriberService(
|
||||||
IBraintreeGateway braintreeGateway,
|
IBraintreeGateway braintreeGateway,
|
||||||
|
IFeatureService featureService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ILogger<SubscriberService> logger,
|
ILogger<SubscriberService> logger,
|
||||||
ISetupIntentCache setupIntentCache,
|
ISetupIntentCache setupIntentCache,
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ITaxService taxService) : ISubscriberService
|
ITaxService taxService,
|
||||||
|
IAutomaticTaxFactory automaticTaxFactory) : ISubscriberService
|
||||||
{
|
{
|
||||||
public async Task CancelSubscription(
|
public async Task CancelSubscription(
|
||||||
ISubscriber subscriber,
|
ISubscriber subscriber,
|
||||||
@ -438,7 +441,8 @@ public class SubscriberService(
|
|||||||
ArgumentNullException.ThrowIfNull(subscriber);
|
ArgumentNullException.ThrowIfNull(subscriber);
|
||||||
ArgumentNullException.ThrowIfNull(tokenizedPaymentSource);
|
ArgumentNullException.ThrowIfNull(tokenizedPaymentSource);
|
||||||
|
|
||||||
var customer = await GetCustomerOrThrow(subscriber);
|
var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
|
||||||
|
var customer = await GetCustomerOrThrow(subscriber, customerGetOptions);
|
||||||
|
|
||||||
var (type, token) = tokenizedPaymentSource;
|
var (type, token) = tokenizedPaymentSource;
|
||||||
|
|
||||||
@ -597,7 +601,7 @@ public class SubscriberService(
|
|||||||
Expand = ["subscriptions", "tax", "tax_ids"]
|
Expand = ["subscriptions", "tax", "tax_ids"]
|
||||||
});
|
});
|
||||||
|
|
||||||
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
customer = await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
||||||
{
|
{
|
||||||
Address = new AddressOptions
|
Address = new AddressOptions
|
||||||
{
|
{
|
||||||
@ -607,7 +611,8 @@ public class SubscriberService(
|
|||||||
Line2 = taxInformation.Line2,
|
Line2 = taxInformation.Line2,
|
||||||
City = taxInformation.City,
|
City = taxInformation.City,
|
||||||
State = taxInformation.State
|
State = taxInformation.State
|
||||||
}
|
},
|
||||||
|
Expand = ["subscriptions", "tax", "tax_ids"]
|
||||||
});
|
});
|
||||||
|
|
||||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||||
@ -661,21 +666,42 @@ public class SubscriberService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SubscriberIsEligibleForAutomaticTax(subscriber, customer))
|
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
{
|
{
|
||||||
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
|
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
|
||||||
new SubscriptionUpdateOptions
|
{
|
||||||
|
var subscriptionGetOptions = new SubscriptionGetOptions
|
||||||
{
|
{
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
Expand = ["customer.tax", "customer.tax_ids"]
|
||||||
});
|
};
|
||||||
|
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
|
||||||
|
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id));
|
||||||
|
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||||
|
var automaticTaxOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
|
||||||
|
if (automaticTaxOptions?.AutomaticTax?.Enabled != null)
|
||||||
|
{
|
||||||
|
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, automaticTaxOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (SubscriberIsEligibleForAutomaticTax(subscriber, customer))
|
||||||
|
{
|
||||||
|
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
|
||||||
|
new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer)
|
bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer)
|
||||||
=> !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) &&
|
=> !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) &&
|
||||||
(localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) &&
|
(localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) &&
|
||||||
localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
|
localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task VerifyBankAccount(
|
public async Task VerifyBankAccount(
|
||||||
|
@ -148,6 +148,8 @@ public static class FeatureFlagKeys
|
|||||||
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
|
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
|
||||||
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
|
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
|
||||||
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
|
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
|
||||||
|
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
|
||||||
|
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
|
||||||
|
|
||||||
/* Key Management Team */
|
/* Key Management Team */
|
||||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||||
@ -169,6 +171,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
|
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
|
||||||
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
|
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
|
||||||
public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias";
|
public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias";
|
||||||
|
|
||||||
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
|
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
|
||||||
|
|
||||||
/* Platform Team */
|
/* Platform Team */
|
||||||
|
@ -20,6 +20,7 @@ public class RotateUserAccountKeysData
|
|||||||
public IEnumerable<EmergencyAccess> EmergencyAccesses { get; set; }
|
public IEnumerable<EmergencyAccess> EmergencyAccesses { get; set; }
|
||||||
public IReadOnlyList<OrganizationUser> OrganizationUsers { get; set; }
|
public IReadOnlyList<OrganizationUser> OrganizationUsers { get; set; }
|
||||||
public IEnumerable<WebAuthnLoginRotateKeyData> WebAuthnKeys { get; set; }
|
public IEnumerable<WebAuthnLoginRotateKeyData> WebAuthnKeys { get; set; }
|
||||||
|
public IEnumerable<Device> DeviceKeys { get; set; }
|
||||||
|
|
||||||
// User vault data encrypted by the userkey
|
// User vault data encrypted by the userkey
|
||||||
public IEnumerable<Cipher> Ciphers { get; set; }
|
public IEnumerable<Cipher> Ciphers { get; set; }
|
||||||
|
@ -20,6 +20,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
private readonly ISendRepository _sendRepository;
|
private readonly ISendRepository _sendRepository;
|
||||||
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
|
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
private readonly IDeviceRepository _deviceRepository;
|
||||||
private readonly IPushNotificationService _pushService;
|
private readonly IPushNotificationService _pushService;
|
||||||
private readonly IdentityErrorDescriber _identityErrorDescriber;
|
private readonly IdentityErrorDescriber _identityErrorDescriber;
|
||||||
private readonly IWebAuthnCredentialRepository _credentialRepository;
|
private readonly IWebAuthnCredentialRepository _credentialRepository;
|
||||||
@ -42,6 +43,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository,
|
public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository,
|
||||||
ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository,
|
ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository,
|
||||||
IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository,
|
IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IDeviceRepository deviceRepository,
|
||||||
IPasswordHasher<User> passwordHasher,
|
IPasswordHasher<User> passwordHasher,
|
||||||
IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository)
|
IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository)
|
||||||
{
|
{
|
||||||
@ -52,6 +54,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
_sendRepository = sendRepository;
|
_sendRepository = sendRepository;
|
||||||
_emergencyAccessRepository = emergencyAccessRepository;
|
_emergencyAccessRepository = emergencyAccessRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
|
_deviceRepository = deviceRepository;
|
||||||
_pushService = pushService;
|
_pushService = pushService;
|
||||||
_identityErrorDescriber = errors;
|
_identityErrorDescriber = errors;
|
||||||
_credentialRepository = credentialRepository;
|
_credentialRepository = credentialRepository;
|
||||||
@ -127,6 +130,11 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys));
|
saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (model.DeviceKeys.Any())
|
||||||
|
{
|
||||||
|
saveEncryptedDataActions.Add(_deviceRepository.UpdateKeysForRotationAsync(user.Id, model.DeviceKeys));
|
||||||
|
}
|
||||||
|
|
||||||
await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);
|
await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);
|
||||||
await _pushService.PushLogOutAsync(user.Id);
|
await _pushService.PushLogOutAsync(user.Id);
|
||||||
return IdentityResult.Success;
|
return IdentityResult.Success;
|
||||||
|
@ -6,11 +6,8 @@
|
|||||||
<table border="0" cellpadding="0" cellspacing="0" width="100%"
|
<table border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||||
style="padding-left:30px; padding-right: 5px; padding-top: 20px;">
|
style="padding-left:30px; padding-right: 5px; padding-top: 20px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 24px; color: #ffffff; line-height: 32px; font-weight: 500; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||||
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 24px; color: #ffffff; line-height: 32px; font-weight: 500; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
{{OrgName}} has identified {{TaskCount}} critical {{plurality TaskCount "login" "logins"}} that {{plurality TaskCount "requires" "require"}} a password change
|
||||||
{{OrgName}} has identified {{TaskCount}} critical login{{#if TaskCountPlural}}s{{/if}} that require{{#unless
|
|
||||||
TaskCountPlural}}s{{/unless}} a
|
|
||||||
password change
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
{{#>FullTextLayout}}
|
{{#>FullTextLayout}}
|
||||||
{{OrgName}} has identified {{TaskCount}} critical login{{#if TaskCountPlural}}s{{/if}} that require{{#unless
|
{{OrgName}} has identified {{TaskCount}} critical {{plurality TaskCount "login" "logins"}} that {{plurality TaskCount "requires" "require"}} a password change
|
||||||
TaskCountPlural}}s{{/unless}} a
|
|
||||||
password change
|
|
||||||
|
|
||||||
{{>@partial-block}}
|
{{>@partial-block}}
|
||||||
|
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
using Bit.Core.Billing;
|
|
||||||
using Bit.Core.Billing.Enums;
|
|
||||||
using Bit.Core.Billing.Extensions;
|
|
||||||
using Stripe;
|
|
||||||
using Plan = Bit.Core.Models.StaticStore.Plan;
|
|
||||||
|
|
||||||
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(
|
|
||||||
Plan plan,
|
|
||||||
int previouslyPurchasedSeats,
|
|
||||||
int newlyPurchasedSeats)
|
|
||||||
{
|
|
||||||
if (!plan.Type.SupportsConsolidatedBilling())
|
|
||||||
{
|
|
||||||
throw new BillingException(
|
|
||||||
message: $"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing");
|
|
||||||
}
|
|
||||||
|
|
||||||
_planId = plan.PasswordManager.StripeProviderPortalSeatPlanId;
|
|
||||||
_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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,8 +6,6 @@ public class SecurityTaskNotificationViewModel : BaseMailModel
|
|||||||
|
|
||||||
public int TaskCount { get; set; }
|
public int TaskCount { get; set; }
|
||||||
|
|
||||||
public bool TaskCountPlural => TaskCount != 1;
|
|
||||||
|
|
||||||
public List<string> AdminOwnerEmails { get; set; }
|
public List<string> AdminOwnerEmails { get; set; }
|
||||||
|
|
||||||
public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt";
|
public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt";
|
||||||
|
@ -19,6 +19,7 @@ public class NotificationStatusDetails
|
|||||||
public string? Body { get; set; }
|
public string? Body { get; set; }
|
||||||
public DateTime CreationDate { get; set; }
|
public DateTime CreationDate { get; set; }
|
||||||
public DateTime RevisionDate { get; set; }
|
public DateTime RevisionDate { get; set; }
|
||||||
|
public Guid? TaskId { get; set; }
|
||||||
// Notification Status fields
|
// Notification Status fields
|
||||||
public DateTime? ReadDate { get; set; }
|
public DateTime? ReadDate { get; set; }
|
||||||
public DateTime? DeletedDate { get; set; }
|
public DateTime? DeletedDate { get; set; }
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
@ -16,4 +17,5 @@ public interface IDeviceRepository : IRepository<Device, Guid>
|
|||||||
// other requests.
|
// other requests.
|
||||||
Task<ICollection<DeviceAuthDetails>> GetManyByUserIdWithDeviceAuth(Guid userId);
|
Task<ICollection<DeviceAuthDetails>> GetManyByUserIdWithDeviceAuth(Guid userId);
|
||||||
Task ClearPushTokenAsync(Guid id);
|
Task ClearPushTokenAsync(Guid id);
|
||||||
|
UpdateEncryptedDataForKeyRotation UpdateKeysForRotationAsync(Guid userId, IEnumerable<Device> devices);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Api.Requests.Accounts;
|
using Bit.Core.Billing.Models.Api.Requests.Accounts;
|
||||||
using Bit.Core.Billing.Models.Api.Requests.Organizations;
|
using Bit.Core.Billing.Models.Api.Requests.Organizations;
|
||||||
@ -25,11 +24,6 @@ public interface IPaymentService
|
|||||||
int? newlyPurchasedAdditionalSecretsManagerServiceAccounts,
|
int? newlyPurchasedAdditionalSecretsManagerServiceAccounts,
|
||||||
int newlyPurchasedAdditionalStorage);
|
int newlyPurchasedAdditionalStorage);
|
||||||
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats);
|
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats);
|
||||||
Task<string> AdjustSeats(
|
|
||||||
Provider provider,
|
|
||||||
Plan plan,
|
|
||||||
int currentlySubscribedSeats,
|
|
||||||
int newlySubscribedSeats);
|
|
||||||
Task<string> AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats);
|
Task<string> AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats);
|
||||||
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId);
|
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId);
|
||||||
|
|
||||||
|
@ -794,6 +794,29 @@ public class HandlebarsMailService : IMailService
|
|||||||
|
|
||||||
writer.WriteSafeString($"{outputMessage}");
|
writer.WriteSafeString($"{outputMessage}");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Returns the singular or plural form of a word based on the provided numeric value.
|
||||||
|
Handlebars.RegisterHelper("plurality", (writer, context, parameters) =>
|
||||||
|
{
|
||||||
|
if (parameters.Length != 3)
|
||||||
|
{
|
||||||
|
writer.WriteSafeString(string.Empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var numeric = parameters[0];
|
||||||
|
var singularText = parameters[1].ToString();
|
||||||
|
var pluralText = parameters[2].ToString();
|
||||||
|
|
||||||
|
if (numeric is int number)
|
||||||
|
{
|
||||||
|
writer.WriteSafeString(number == 1 ? singularText : pluralText);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
writer.WriteSafeString(string.Empty);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token)
|
public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token)
|
||||||
|
@ -34,6 +34,9 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendEmailAsync(Models.Mail.MailMessage message)
|
public async Task SendEmailAsync(Models.Mail.MailMessage message)
|
||||||
|
=> await SendEmailAsync(message, CancellationToken.None);
|
||||||
|
|
||||||
|
public async Task SendEmailAsync(Models.Mail.MailMessage message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var mimeMessage = new MimeMessage();
|
var mimeMessage = new MimeMessage();
|
||||||
mimeMessage.From.Add(new MailboxAddress(_globalSettings.SiteName, _replyEmail));
|
mimeMessage.From.Add(new MailboxAddress(_globalSettings.SiteName, _replyEmail));
|
||||||
@ -76,25 +79,37 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
|
|||||||
if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl &&
|
if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl &&
|
||||||
_globalSettings.Mail.Smtp.Port == 25)
|
_globalSettings.Mail.Smtp.Port == 25)
|
||||||
{
|
{
|
||||||
await client.ConnectAsync(_globalSettings.Mail.Smtp.Host, _globalSettings.Mail.Smtp.Port,
|
await client.ConnectAsync(
|
||||||
MailKit.Security.SecureSocketOptions.None);
|
_globalSettings.Mail.Smtp.Host,
|
||||||
|
_globalSettings.Mail.Smtp.Port,
|
||||||
|
MailKit.Security.SecureSocketOptions.None,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var useSsl = _globalSettings.Mail.Smtp.Port == 587 && !_globalSettings.Mail.Smtp.SslOverride ?
|
var useSsl = _globalSettings.Mail.Smtp.Port == 587 && !_globalSettings.Mail.Smtp.SslOverride ?
|
||||||
false : _globalSettings.Mail.Smtp.Ssl;
|
false : _globalSettings.Mail.Smtp.Ssl;
|
||||||
await client.ConnectAsync(_globalSettings.Mail.Smtp.Host, _globalSettings.Mail.Smtp.Port, useSsl);
|
await client.ConnectAsync(
|
||||||
|
_globalSettings.Mail.Smtp.Host,
|
||||||
|
_globalSettings.Mail.Smtp.Port,
|
||||||
|
useSsl,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Username) &&
|
if (CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Username) &&
|
||||||
CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Password))
|
CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Password))
|
||||||
{
|
{
|
||||||
await client.AuthenticateAsync(_globalSettings.Mail.Smtp.Username,
|
await client.AuthenticateAsync(
|
||||||
_globalSettings.Mail.Smtp.Password);
|
_globalSettings.Mail.Smtp.Username,
|
||||||
|
_globalSettings.Mail.Smtp.Password,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.SendAsync(mimeMessage);
|
await client.SendAsync(mimeMessage, cancellationToken);
|
||||||
await client.DisconnectAsync(true);
|
await client.DisconnectAsync(true, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
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.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
@ -9,6 +8,8 @@ using Bit.Core.Billing.Models.Api.Responses;
|
|||||||
using Bit.Core.Billing.Models.Business;
|
using Bit.Core.Billing.Models.Business;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
|
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
@ -16,6 +17,7 @@ using Bit.Core.Models.BitStripe;
|
|||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using PaymentMethod = Stripe.PaymentMethod;
|
using PaymentMethod = Stripe.PaymentMethod;
|
||||||
@ -36,6 +38,8 @@ public class StripePaymentService : IPaymentService
|
|||||||
private readonly ITaxService _taxService;
|
private readonly ITaxService _taxService;
|
||||||
private readonly ISubscriberService _subscriberService;
|
private readonly ISubscriberService _subscriberService;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
|
private readonly IAutomaticTaxFactory _automaticTaxFactory;
|
||||||
|
private readonly IAutomaticTaxStrategy _personalUseTaxStrategy;
|
||||||
|
|
||||||
public StripePaymentService(
|
public StripePaymentService(
|
||||||
ITransactionRepository transactionRepository,
|
ITransactionRepository transactionRepository,
|
||||||
@ -46,7 +50,9 @@ public class StripePaymentService : IPaymentService
|
|||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
ITaxService taxService,
|
ITaxService taxService,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
IPricingClient pricingClient)
|
IPricingClient pricingClient,
|
||||||
|
IAutomaticTaxFactory automaticTaxFactory,
|
||||||
|
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy)
|
||||||
{
|
{
|
||||||
_transactionRepository = transactionRepository;
|
_transactionRepository = transactionRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@ -57,6 +63,8 @@ public class StripePaymentService : IPaymentService
|
|||||||
_taxService = taxService;
|
_taxService = taxService;
|
||||||
_subscriberService = subscriberService;
|
_subscriberService = subscriberService;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
|
_automaticTaxFactory = automaticTaxFactory;
|
||||||
|
_personalUseTaxStrategy = personalUseTaxStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ChangeOrganizationSponsorship(
|
private async Task ChangeOrganizationSponsorship(
|
||||||
@ -91,9 +99,7 @@ public class StripePaymentService : IPaymentService
|
|||||||
SubscriptionUpdate subscriptionUpdate, bool invoiceNow = false)
|
SubscriptionUpdate subscriptionUpdate, bool invoiceNow = false)
|
||||||
{
|
{
|
||||||
// remember, when in doubt, throw
|
// remember, when in doubt, throw
|
||||||
var subGetOptions = new SubscriptionGetOptions();
|
var subGetOptions = new SubscriptionGetOptions { Expand = ["customer.tax", "customer.tax_ids"] };
|
||||||
// subGetOptions.AddExpand("customer");
|
|
||||||
subGetOptions.AddExpand("customer.tax");
|
|
||||||
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions);
|
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions);
|
||||||
if (sub == null)
|
if (sub == null)
|
||||||
{
|
{
|
||||||
@ -124,7 +130,19 @@ public class StripePaymentService : IPaymentService
|
|||||||
new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" };
|
new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" };
|
||||||
}
|
}
|
||||||
|
|
||||||
subUpdateOptions.EnableAutomaticTax(sub.Customer, sub);
|
if (subscriptionUpdate is CompleteSubscriptionUpdate)
|
||||||
|
{
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
|
{
|
||||||
|
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, updatedItemOptions.Select(x => x.Plan ?? x.Price));
|
||||||
|
var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||||
|
automaticTaxStrategy.SetUpdateOptions(subUpdateOptions, sub);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subUpdateOptions.EnableAutomaticTax(sub.Customer, sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!subscriptionUpdate.UpdateNeeded(sub))
|
if (!subscriptionUpdate.UpdateNeeded(sub))
|
||||||
{
|
{
|
||||||
@ -232,18 +250,6 @@ public class StripePaymentService : IPaymentService
|
|||||||
public Task<string> AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) =>
|
public Task<string> AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) =>
|
||||||
FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats));
|
FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats));
|
||||||
|
|
||||||
public Task<string> AdjustSeats(
|
|
||||||
Provider provider,
|
|
||||||
StaticStore.Plan plan,
|
|
||||||
int currentlySubscribedSeats,
|
|
||||||
int newlySubscribedSeats)
|
|
||||||
=> FinalizeSubscriptionChangeAsync(
|
|
||||||
provider,
|
|
||||||
new ProviderSubscriptionUpdate(
|
|
||||||
plan,
|
|
||||||
currentlySubscribedSeats,
|
|
||||||
newlySubscribedSeats));
|
|
||||||
|
|
||||||
public Task<string> AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) =>
|
public Task<string> AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) =>
|
||||||
FinalizeSubscriptionChangeAsync(
|
FinalizeSubscriptionChangeAsync(
|
||||||
organization,
|
organization,
|
||||||
@ -811,21 +817,46 @@ public class StripePaymentService : IPaymentService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId) &&
|
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
customer.Subscriptions.Any(sub =>
|
|
||||||
sub.Id == subscriber.GatewaySubscriptionId &&
|
|
||||||
!sub.AutomaticTax.Enabled) &&
|
|
||||||
customer.HasTaxLocationVerified())
|
|
||||||
{
|
{
|
||||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
|
||||||
{
|
{
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
var subscriptionGetOptions = new SubscriptionGetOptions
|
||||||
DefaultTaxRates = []
|
{
|
||||||
};
|
Expand = ["customer.tax", "customer.tax_ids"]
|
||||||
|
};
|
||||||
|
var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
|
||||||
|
|
||||||
_ = await _stripeAdapter.SubscriptionUpdateAsync(
|
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id));
|
||||||
subscriber.GatewaySubscriptionId,
|
var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||||
subscriptionUpdateOptions);
|
var subscriptionUpdateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
if (subscriptionUpdateOptions != null)
|
||||||
|
{
|
||||||
|
_ = await _stripeAdapter.SubscriptionUpdateAsync(
|
||||||
|
subscriber.GatewaySubscriptionId,
|
||||||
|
subscriptionUpdateOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId) &&
|
||||||
|
customer.Subscriptions.Any(sub =>
|
||||||
|
sub.Id == subscriber.GatewaySubscriptionId &&
|
||||||
|
!sub.AutomaticTax.Enabled) &&
|
||||||
|
customer.HasTaxLocationVerified())
|
||||||
|
{
|
||||||
|
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
||||||
|
DefaultTaxRates = []
|
||||||
|
};
|
||||||
|
|
||||||
|
_ = await _stripeAdapter.SubscriptionUpdateAsync(
|
||||||
|
subscriber.GatewaySubscriptionId,
|
||||||
|
subscriptionUpdateOptions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@ -1214,6 +1245,8 @@ public class StripePaymentService : IPaymentService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_personalUseTaxStrategy.SetInvoiceCreatePreviewOptions(options);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
|
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
|
||||||
@ -1256,10 +1289,6 @@ public class StripePaymentService : IPaymentService
|
|||||||
|
|
||||||
var options = new InvoiceCreatePreviewOptions
|
var options = new InvoiceCreatePreviewOptions
|
||||||
{
|
{
|
||||||
AutomaticTax = new InvoiceAutomaticTaxOptions
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
},
|
|
||||||
Currency = "usd",
|
Currency = "usd",
|
||||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||||
{
|
{
|
||||||
@ -1347,9 +1376,11 @@ public class StripePaymentService : IPaymentService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Customer gatewayCustomer = null;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(gatewayCustomerId))
|
if (!string.IsNullOrWhiteSpace(gatewayCustomerId))
|
||||||
{
|
{
|
||||||
var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
|
gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
|
||||||
|
|
||||||
if (gatewayCustomer.Discount != null)
|
if (gatewayCustomer.Discount != null)
|
||||||
{
|
{
|
||||||
@ -1367,6 +1398,10 @@ public class StripePaymentService : IPaymentService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var automaticTaxFactoryParameters = new AutomaticTaxFactoryParameters(parameters.PasswordManager.Plan);
|
||||||
|
var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxFactoryParameters);
|
||||||
|
automaticTaxStrategy.SetInvoiceCreatePreviewOptions(options);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
|
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
|
||||||
|
@ -15,7 +15,7 @@ public interface ICipherService
|
|||||||
long requestLength, Guid savingUserId, bool orgAdmin = false);
|
long requestLength, Guid savingUserId, bool orgAdmin = false);
|
||||||
Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, string key, long requestLength,
|
Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, string key, long requestLength,
|
||||||
string attachmentId, Guid organizationShareId);
|
string attachmentId, Guid organizationShareId);
|
||||||
Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false);
|
Task DeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false);
|
||||||
Task DeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false);
|
Task DeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false);
|
||||||
Task<DeleteAttachmentResponseData> DeleteAttachmentAsync(Cipher cipher, string attachmentId, Guid deletingUserId, bool orgAdmin = false);
|
Task<DeleteAttachmentResponseData> DeleteAttachmentAsync(Cipher cipher, string attachmentId, Guid deletingUserId, bool orgAdmin = false);
|
||||||
Task PurgeAsync(Guid organizationId);
|
Task PurgeAsync(Guid organizationId);
|
||||||
@ -27,9 +27,9 @@ public interface ICipherService
|
|||||||
Task ShareManyAsync(IEnumerable<(Cipher cipher, DateTime? lastKnownRevisionDate)> ciphers, Guid organizationId,
|
Task ShareManyAsync(IEnumerable<(Cipher cipher, DateTime? lastKnownRevisionDate)> ciphers, Guid organizationId,
|
||||||
IEnumerable<Guid> collectionIds, Guid sharingUserId);
|
IEnumerable<Guid> collectionIds, Guid sharingUserId);
|
||||||
Task SaveCollectionsAsync(Cipher cipher, IEnumerable<Guid> collectionIds, Guid savingUserId, bool orgAdmin);
|
Task SaveCollectionsAsync(Cipher cipher, IEnumerable<Guid> collectionIds, Guid savingUserId, bool orgAdmin);
|
||||||
Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false);
|
Task SoftDeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false);
|
||||||
Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false);
|
Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false);
|
||||||
Task RestoreAsync(Cipher cipher, Guid restoringUserId, bool orgAdmin = false);
|
Task RestoreAsync(CipherDetails cipherDetails, Guid restoringUserId, bool orgAdmin = false);
|
||||||
Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false);
|
Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false);
|
||||||
Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId);
|
Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId);
|
||||||
Task<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId);
|
Task<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId);
|
||||||
|
@ -14,6 +14,7 @@ using Bit.Core.Tools.Enums;
|
|||||||
using Bit.Core.Tools.Models.Business;
|
using Bit.Core.Tools.Models.Business;
|
||||||
using Bit.Core.Tools.Services;
|
using Bit.Core.Tools.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using Bit.Core.Vault.Authorization.Permissions;
|
||||||
using Bit.Core.Vault.Entities;
|
using Bit.Core.Vault.Entities;
|
||||||
using Bit.Core.Vault.Enums;
|
using Bit.Core.Vault.Enums;
|
||||||
using Bit.Core.Vault.Models.Data;
|
using Bit.Core.Vault.Models.Data;
|
||||||
@ -44,6 +45,7 @@ public class CipherService : ICipherService
|
|||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery;
|
private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery;
|
||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
|
private readonly IApplicationCacheService _applicationCacheService;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public CipherService(
|
public CipherService(
|
||||||
@ -64,6 +66,7 @@ public class CipherService : ICipherService
|
|||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery,
|
IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
|
IApplicationCacheService applicationCacheService,
|
||||||
IFeatureService featureService)
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_cipherRepository = cipherRepository;
|
_cipherRepository = cipherRepository;
|
||||||
@ -83,6 +86,7 @@ public class CipherService : ICipherService
|
|||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery;
|
_getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery;
|
||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
|
_applicationCacheService = applicationCacheService;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -421,19 +425,19 @@ public class CipherService : ICipherService
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false)
|
public async Task DeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false)
|
||||||
{
|
{
|
||||||
if (!orgAdmin && !(await UserCanEditAsync(cipher, deletingUserId)))
|
if (!orgAdmin && !await UserCanDeleteAsync(cipherDetails, deletingUserId))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("You do not have permissions to delete this.");
|
throw new BadRequestException("You do not have permissions to delete this.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _cipherRepository.DeleteAsync(cipher);
|
await _cipherRepository.DeleteAsync(cipherDetails);
|
||||||
await _attachmentStorageService.DeleteAttachmentsForCipherAsync(cipher.Id);
|
await _attachmentStorageService.DeleteAttachmentsForCipherAsync(cipherDetails.Id);
|
||||||
await _eventService.LogCipherEventAsync(cipher, EventType.Cipher_Deleted);
|
await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_Deleted);
|
||||||
|
|
||||||
// push
|
// push
|
||||||
await _pushService.PushSyncCipherDeleteAsync(cipher);
|
await _pushService.PushSyncCipherDeleteAsync(cipherDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false)
|
public async Task DeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false)
|
||||||
@ -450,8 +454,8 @@ public class CipherService : ICipherService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId);
|
var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId);
|
||||||
deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(x => (Cipher)x).ToList();
|
var filteredCiphers = await FilterCiphersByDeletePermission(ciphers, cipherIdsSet, deletingUserId);
|
||||||
|
deletingCiphers = filteredCiphers.Select(c => (Cipher)c).ToList();
|
||||||
await _cipherRepository.DeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId);
|
await _cipherRepository.DeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -703,33 +707,26 @@ public class CipherService : ICipherService
|
|||||||
await _pushService.PushSyncCipherUpdateAsync(cipher, collectionIds);
|
await _pushService.PushSyncCipherUpdateAsync(cipher, collectionIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false)
|
public async Task SoftDeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false)
|
||||||
{
|
{
|
||||||
if (!orgAdmin && !(await UserCanEditAsync(cipher, deletingUserId)))
|
if (!orgAdmin && !await UserCanDeleteAsync(cipherDetails, deletingUserId))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("You do not have permissions to soft delete this.");
|
throw new BadRequestException("You do not have permissions to soft delete this.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cipher.DeletedDate.HasValue)
|
if (cipherDetails.DeletedDate.HasValue)
|
||||||
{
|
{
|
||||||
// Already soft-deleted, we can safely ignore this
|
// Already soft-deleted, we can safely ignore this
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cipher.DeletedDate = cipher.RevisionDate = DateTime.UtcNow;
|
cipherDetails.DeletedDate = cipherDetails.RevisionDate = DateTime.UtcNow;
|
||||||
|
|
||||||
if (cipher is CipherDetails details)
|
await _cipherRepository.UpsertAsync(cipherDetails);
|
||||||
{
|
await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted);
|
||||||
await _cipherRepository.UpsertAsync(details);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await _cipherRepository.UpsertAsync(cipher);
|
|
||||||
}
|
|
||||||
await _eventService.LogCipherEventAsync(cipher, EventType.Cipher_SoftDeleted);
|
|
||||||
|
|
||||||
// push
|
// push
|
||||||
await _pushService.PushSyncCipherUpdateAsync(cipher, null);
|
await _pushService.PushSyncCipherUpdateAsync(cipherDetails, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId, bool orgAdmin)
|
public async Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId, bool orgAdmin)
|
||||||
@ -746,8 +743,8 @@ public class CipherService : ICipherService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId);
|
var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId);
|
||||||
deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(x => (Cipher)x).ToList();
|
var filteredCiphers = await FilterCiphersByDeletePermission(ciphers, cipherIdsSet, deletingUserId);
|
||||||
|
deletingCiphers = filteredCiphers.Select(c => (Cipher)c).ToList();
|
||||||
await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId);
|
await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -762,34 +759,27 @@ public class CipherService : ICipherService
|
|||||||
await _pushService.PushSyncCiphersAsync(deletingUserId);
|
await _pushService.PushSyncCiphersAsync(deletingUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RestoreAsync(Cipher cipher, Guid restoringUserId, bool orgAdmin = false)
|
public async Task RestoreAsync(CipherDetails cipherDetails, Guid restoringUserId, bool orgAdmin = false)
|
||||||
{
|
{
|
||||||
if (!orgAdmin && !(await UserCanEditAsync(cipher, restoringUserId)))
|
if (!orgAdmin && !await UserCanRestoreAsync(cipherDetails, restoringUserId))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("You do not have permissions to delete this.");
|
throw new BadRequestException("You do not have permissions to delete this.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cipher.DeletedDate.HasValue)
|
if (!cipherDetails.DeletedDate.HasValue)
|
||||||
{
|
{
|
||||||
// Already restored, we can safely ignore this
|
// Already restored, we can safely ignore this
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cipher.DeletedDate = null;
|
cipherDetails.DeletedDate = null;
|
||||||
cipher.RevisionDate = DateTime.UtcNow;
|
cipherDetails.RevisionDate = DateTime.UtcNow;
|
||||||
|
|
||||||
if (cipher is CipherDetails details)
|
await _cipherRepository.UpsertAsync(cipherDetails);
|
||||||
{
|
await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_Restored);
|
||||||
await _cipherRepository.UpsertAsync(details);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await _cipherRepository.UpsertAsync(cipher);
|
|
||||||
}
|
|
||||||
await _eventService.LogCipherEventAsync(cipher, EventType.Cipher_Restored);
|
|
||||||
|
|
||||||
// push
|
// push
|
||||||
await _pushService.PushSyncCipherUpdateAsync(cipher, null);
|
await _pushService.PushSyncCipherUpdateAsync(cipherDetails, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false)
|
public async Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false)
|
||||||
@ -812,8 +802,8 @@ public class CipherService : ICipherService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(restoringUserId);
|
var ciphers = await _cipherRepository.GetManyByUserIdAsync(restoringUserId);
|
||||||
restoringCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(c => (CipherOrganizationDetails)c).ToList();
|
var filteredCiphers = await FilterCiphersByDeletePermission(ciphers, cipherIdsSet, restoringUserId);
|
||||||
|
restoringCiphers = filteredCiphers.Select(c => (CipherOrganizationDetails)c).ToList();
|
||||||
revisionDate = await _cipherRepository.RestoreAsync(restoringCiphers.Select(c => c.Id), restoringUserId);
|
revisionDate = await _cipherRepository.RestoreAsync(restoringCiphers.Select(c => c.Id), restoringUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -844,6 +834,34 @@ public class CipherService : ICipherService
|
|||||||
return await _cipherRepository.GetCanEditByIdAsync(userId, cipher.Id);
|
return await _cipherRepository.GetCanEditByIdAsync(userId, cipher.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<bool> UserCanDeleteAsync(CipherDetails cipher, Guid userId)
|
||||||
|
{
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
|
||||||
|
{
|
||||||
|
return await UserCanEditAsync(cipher, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userService.GetUserByIdAsync(userId);
|
||||||
|
var organizationAbility = cipher.OrganizationId.HasValue ?
|
||||||
|
await _applicationCacheService.GetOrganizationAbilityAsync(cipher.OrganizationId.Value) : null;
|
||||||
|
|
||||||
|
return NormalCipherPermissions.CanDelete(user, cipher, organizationAbility);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> UserCanRestoreAsync(CipherDetails cipher, Guid userId)
|
||||||
|
{
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
|
||||||
|
{
|
||||||
|
return await UserCanEditAsync(cipher, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userService.GetUserByIdAsync(userId);
|
||||||
|
var organizationAbility = cipher.OrganizationId.HasValue ?
|
||||||
|
await _applicationCacheService.GetOrganizationAbilityAsync(cipher.OrganizationId.Value) : null;
|
||||||
|
|
||||||
|
return NormalCipherPermissions.CanRestore(user, cipher, organizationAbility);
|
||||||
|
}
|
||||||
|
|
||||||
private void ValidateCipherLastKnownRevisionDateAsync(Cipher cipher, DateTime? lastKnownRevisionDate)
|
private void ValidateCipherLastKnownRevisionDateAsync(Cipher cipher, DateTime? lastKnownRevisionDate)
|
||||||
{
|
{
|
||||||
if (cipher.Id == default || !lastKnownRevisionDate.HasValue)
|
if (cipher.Id == default || !lastKnownRevisionDate.HasValue)
|
||||||
@ -1010,4 +1028,35 @@ public class CipherService : ICipherService
|
|||||||
cipher.Data = JsonSerializer.Serialize(newCipherData);
|
cipher.Data = JsonSerializer.Serialize(newCipherData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This method is used to filter ciphers based on the user's permissions to delete them.
|
||||||
|
// It supports both the old and new logic depending on the feature flag.
|
||||||
|
private async Task<List<T>> FilterCiphersByDeletePermission<T>(
|
||||||
|
IEnumerable<T> ciphers,
|
||||||
|
HashSet<Guid> cipherIdsSet,
|
||||||
|
Guid userId) where T : CipherDetails
|
||||||
|
{
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
|
||||||
|
{
|
||||||
|
return ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userService.GetUserByIdAsync(userId);
|
||||||
|
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||||
|
|
||||||
|
var filteredCiphers = ciphers
|
||||||
|
.Where(c => cipherIdsSet.Contains(c.Id))
|
||||||
|
.GroupBy(c => c.OrganizationId)
|
||||||
|
.SelectMany(group =>
|
||||||
|
{
|
||||||
|
var organizationAbility = group.Key.HasValue &&
|
||||||
|
organizationAbilities.TryGetValue(group.Key.Value, out var ability) ?
|
||||||
|
ability : null;
|
||||||
|
|
||||||
|
return group.Where(c => NormalCipherPermissions.CanDelete(user, c, organizationAbility));
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return filteredCiphers;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data.Organizations;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Infrastructure.Dapper.Repositories;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.Dapper.AdminConsole.Repositories;
|
||||||
|
|
||||||
|
public class OrganizationIntegrationConfigurationRepository : Repository<OrganizationIntegrationConfiguration, Guid>, IOrganizationIntegrationConfigurationRepository
|
||||||
|
{
|
||||||
|
public OrganizationIntegrationConfigurationRepository(GlobalSettings globalSettings)
|
||||||
|
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public OrganizationIntegrationConfigurationRepository(string connectionString, string readOnlyConnectionString)
|
||||||
|
: base(connectionString, readOnlyConnectionString)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public async Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
|
||||||
|
Guid organizationId,
|
||||||
|
IntegrationType integrationType,
|
||||||
|
EventType eventType)
|
||||||
|
{
|
||||||
|
using (var connection = new SqlConnection(ConnectionString))
|
||||||
|
{
|
||||||
|
var results = await connection.QueryAsync<OrganizationIntegrationConfigurationDetails>(
|
||||||
|
"[dbo].[OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType]",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
EventType = eventType,
|
||||||
|
OrganizationId = organizationId,
|
||||||
|
IntegrationType = integrationType
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.Dapper.Repositories;
|
||||||
|
|
||||||
|
public class OrganizationIntegrationRepository : Repository<OrganizationIntegration, Guid>, IOrganizationIntegrationRepository
|
||||||
|
{
|
||||||
|
public OrganizationIntegrationRepository(GlobalSettings globalSettings)
|
||||||
|
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public OrganizationIntegrationRepository(string connectionString, string readOnlyConnectionString)
|
||||||
|
: base(connectionString, readOnlyConnectionString)
|
||||||
|
{ }
|
||||||
|
}
|
@ -1,8 +1,10 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
@ -109,4 +111,35 @@ public class DeviceRepository : Repository<Device, Guid>, IDeviceRepository
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UpdateEncryptedDataForKeyRotation UpdateKeysForRotationAsync(Guid userId, IEnumerable<Device> devices)
|
||||||
|
{
|
||||||
|
return async (SqlConnection connection, SqlTransaction transaction) =>
|
||||||
|
{
|
||||||
|
const string sql = @"
|
||||||
|
UPDATE D
|
||||||
|
SET
|
||||||
|
D.[EncryptedPublicKey] = UD.[encryptedPublicKey],
|
||||||
|
D.[EncryptedUserKey] = UD.[encryptedUserKey]
|
||||||
|
FROM
|
||||||
|
[dbo].[Device] D
|
||||||
|
INNER JOIN
|
||||||
|
OPENJSON(@DeviceCredentials)
|
||||||
|
WITH (
|
||||||
|
id UNIQUEIDENTIFIER,
|
||||||
|
encryptedPublicKey NVARCHAR(MAX),
|
||||||
|
encryptedUserKey NVARCHAR(MAX)
|
||||||
|
) UD
|
||||||
|
ON UD.[id] = D.[Id]
|
||||||
|
WHERE
|
||||||
|
D.[UserId] = @UserId";
|
||||||
|
var deviceCredentials = CoreHelpers.ClassToJsonData(devices);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
sql,
|
||||||
|
new { UserId = userId, DeviceCredentials = deviceCredentials },
|
||||||
|
transaction: transaction,
|
||||||
|
commandType: CommandType.Text);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data.Organizations;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||||
|
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||||
|
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
|
||||||
|
|
||||||
|
public class OrganizationIntegrationConfigurationRepository : Repository<Core.AdminConsole.Entities.OrganizationIntegrationConfiguration, OrganizationIntegrationConfiguration, Guid>, IOrganizationIntegrationConfigurationRepository
|
||||||
|
{
|
||||||
|
public OrganizationIntegrationConfigurationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||||
|
: base(serviceScopeFactory, mapper, context => context.OrganizationIntegrationConfigurations)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public async Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
|
||||||
|
Guid organizationId,
|
||||||
|
IntegrationType integrationType,
|
||||||
|
EventType eventType)
|
||||||
|
{
|
||||||
|
using (var scope = ServiceScopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var dbContext = GetDatabaseContext(scope);
|
||||||
|
var query = new OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery(
|
||||||
|
organizationId, eventType, integrationType
|
||||||
|
);
|
||||||
|
return await query.Run(dbContext).ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||||
|
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
|
||||||
|
|
||||||
|
public class OrganizationIntegrationRepository : Repository<Core.AdminConsole.Entities.OrganizationIntegration, OrganizationIntegration, Guid>, IOrganizationIntegrationRepository
|
||||||
|
{
|
||||||
|
public OrganizationIntegrationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||||
|
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationIntegrations)
|
||||||
|
{ }
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data.Organizations;
|
||||||
|
|
||||||
|
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||||
|
|
||||||
|
public class OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery : IQuery<OrganizationIntegrationConfigurationDetails>
|
||||||
|
{
|
||||||
|
private readonly Guid _organizationId;
|
||||||
|
private readonly EventType _eventType;
|
||||||
|
private readonly IntegrationType _integrationType;
|
||||||
|
|
||||||
|
public OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery(Guid organizationId, EventType eventType, IntegrationType integrationType)
|
||||||
|
{
|
||||||
|
_organizationId = organizationId;
|
||||||
|
_eventType = eventType;
|
||||||
|
_integrationType = integrationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IQueryable<OrganizationIntegrationConfigurationDetails> Run(DatabaseContext dbContext)
|
||||||
|
{
|
||||||
|
var query = from oic in dbContext.OrganizationIntegrationConfigurations
|
||||||
|
join oi in dbContext.OrganizationIntegrations on oic.OrganizationIntegrationId equals oi.Id into oioic
|
||||||
|
from oi in dbContext.OrganizationIntegrations
|
||||||
|
where oi.OrganizationId == _organizationId &&
|
||||||
|
oi.Type == _integrationType &&
|
||||||
|
oic.EventType == _eventType
|
||||||
|
select new OrganizationIntegrationConfigurationDetails()
|
||||||
|
{
|
||||||
|
Id = oic.Id,
|
||||||
|
OrganizationIntegrationId = oic.OrganizationIntegrationId,
|
||||||
|
IntegrationType = oi.Type,
|
||||||
|
EventType = oic.EventType,
|
||||||
|
Configuration = oic.Configuration,
|
||||||
|
IntegrationConfiguration = oi.Configuration,
|
||||||
|
Template = oic.Template
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
}
|
@ -78,6 +78,8 @@ public static class EntityFrameworkServiceCollectionExtensions
|
|||||||
services.AddSingleton<IMaintenanceRepository, MaintenanceRepository>();
|
services.AddSingleton<IMaintenanceRepository, MaintenanceRepository>();
|
||||||
services.AddSingleton<IOrganizationApiKeyRepository, OrganizationApiKeyRepository>();
|
services.AddSingleton<IOrganizationApiKeyRepository, OrganizationApiKeyRepository>();
|
||||||
services.AddSingleton<IOrganizationConnectionRepository, OrganizationConnectionRepository>();
|
services.AddSingleton<IOrganizationConnectionRepository, OrganizationConnectionRepository>();
|
||||||
|
services.AddSingleton<IOrganizationIntegrationRepository, OrganizationIntegrationRepository>();
|
||||||
|
services.AddSingleton<IOrganizationIntegrationConfigurationRepository, OrganizationIntegrationConfigurationRepository>();
|
||||||
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
|
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
|
||||||
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
|
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
|
||||||
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
|
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
|
||||||
|
@ -52,6 +52,7 @@ public class NotificationStatusDetailsViewQuery(Guid userId, ClientType clientTy
|
|||||||
ClientType = x.n.ClientType,
|
ClientType = x.n.ClientType,
|
||||||
UserId = x.n.UserId,
|
UserId = x.n.UserId,
|
||||||
OrganizationId = x.n.OrganizationId,
|
OrganizationId = x.n.OrganizationId,
|
||||||
|
TaskId = x.n.TaskId,
|
||||||
Title = x.n.Title,
|
Title = x.n.Title,
|
||||||
Body = x.n.Body,
|
Body = x.n.Body,
|
||||||
CreationDate = x.n.CreationDate,
|
CreationDate = x.n.CreationDate,
|
||||||
|
@ -55,6 +55,8 @@ public class DatabaseContext : DbContext
|
|||||||
public DbSet<OrganizationApiKey> OrganizationApiKeys { get; set; }
|
public DbSet<OrganizationApiKey> OrganizationApiKeys { get; set; }
|
||||||
public DbSet<OrganizationSponsorship> OrganizationSponsorships { get; set; }
|
public DbSet<OrganizationSponsorship> OrganizationSponsorships { get; set; }
|
||||||
public DbSet<OrganizationConnection> OrganizationConnections { get; set; }
|
public DbSet<OrganizationConnection> OrganizationConnections { get; set; }
|
||||||
|
public DbSet<OrganizationIntegration> OrganizationIntegrations { get; set; }
|
||||||
|
public DbSet<OrganizationIntegrationConfiguration> OrganizationIntegrationConfigurations { get; set; }
|
||||||
public DbSet<OrganizationUser> OrganizationUsers { get; set; }
|
public DbSet<OrganizationUser> OrganizationUsers { get; set; }
|
||||||
public DbSet<Policy> Policies { get; set; }
|
public DbSet<Policy> Policies { get; set; }
|
||||||
public DbSet<Provider> Providers { get; set; }
|
public DbSet<Provider> Providers { get; set; }
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Infrastructure.EntityFramework.Auth.Repositories.Queries;
|
using Bit.Infrastructure.EntityFramework.Auth.Repositories.Queries;
|
||||||
@ -91,4 +92,30 @@ public class DeviceRepository : Repository<Core.Entities.Device, Device, Guid>,
|
|||||||
return await query.GetQuery(dbContext, userId, expirationMinutes).ToListAsync();
|
return await query.GetQuery(dbContext, userId, expirationMinutes).ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UpdateEncryptedDataForKeyRotation UpdateKeysForRotationAsync(Guid userId, IEnumerable<Core.Entities.Device> devices)
|
||||||
|
{
|
||||||
|
return async (_, _) =>
|
||||||
|
{
|
||||||
|
var deviceUpdates = devices.ToList();
|
||||||
|
using var scope = ServiceScopeFactory.CreateScope();
|
||||||
|
var dbContext = GetDatabaseContext(scope);
|
||||||
|
var userDevices = await GetDbSet(dbContext)
|
||||||
|
.Where(device => device.UserId == userId)
|
||||||
|
.ToListAsync();
|
||||||
|
var userDevicesWithUpdatesPending = userDevices
|
||||||
|
.Where(existingDevice => deviceUpdates.Any(updatedDevice => updatedDevice.Id == existingDevice.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var deviceToUpdate in userDevicesWithUpdatesPending)
|
||||||
|
{
|
||||||
|
var deviceUpdate = deviceUpdates.First(deviceUpdate => deviceUpdate.Id == deviceToUpdate.Id);
|
||||||
|
deviceToUpdate.EncryptedPublicKey = deviceUpdate.EncryptedPublicKey;
|
||||||
|
deviceToUpdate.EncryptedUserKey = deviceUpdate.EncryptedUserKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -135,6 +135,11 @@ public static class HubHelpers
|
|||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
case PushType.PendingSecurityTasks:
|
||||||
|
var pendingTasksData = JsonSerializer.Deserialize<PushNotificationData<UserPushNotification>>(notificationJson, _deserializerOptions);
|
||||||
|
await hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString())
|
||||||
|
.SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,20 @@
|
|||||||
CREATE VIEW [dbo].[NotificationStatusDetailsView]
|
CREATE VIEW [dbo].[NotificationStatusDetailsView]
|
||||||
AS
|
AS
|
||||||
SELECT
|
SELECT
|
||||||
N.*,
|
N.[Id],
|
||||||
NS.UserId AS NotificationStatusUserId,
|
N.[Priority],
|
||||||
NS.ReadDate,
|
N.[Global],
|
||||||
NS.DeletedDate
|
N.[ClientType],
|
||||||
|
N.[UserId],
|
||||||
|
N.[OrganizationId],
|
||||||
|
N.[Title],
|
||||||
|
N.[Body],
|
||||||
|
N.[CreationDate],
|
||||||
|
N.[RevisionDate],
|
||||||
|
N.[TaskId],
|
||||||
|
NS.[UserId] AS [NotificationStatusUserId],
|
||||||
|
NS.[ReadDate],
|
||||||
|
NS.[DeletedDate]
|
||||||
FROM
|
FROM
|
||||||
[dbo].[Notification] AS N
|
[dbo].[Notification] AS N
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
|
@ -29,6 +29,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
|||||||
private readonly ApiApplicationFactory _factory;
|
private readonly ApiApplicationFactory _factory;
|
||||||
private readonly LoginHelper _loginHelper;
|
private readonly LoginHelper _loginHelper;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IDeviceRepository _deviceRepository;
|
||||||
private readonly IPasswordHasher<User> _passwordHasher;
|
private readonly IPasswordHasher<User> _passwordHasher;
|
||||||
private string _ownerEmail = null!;
|
private string _ownerEmail = null!;
|
||||||
|
|
||||||
@ -40,6 +41,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
|||||||
_client = factory.CreateClient();
|
_client = factory.CreateClient();
|
||||||
_loginHelper = new LoginHelper(_factory, _client);
|
_loginHelper = new LoginHelper(_factory, _client);
|
||||||
_userRepository = _factory.GetService<IUserRepository>();
|
_userRepository = _factory.GetService<IUserRepository>();
|
||||||
|
_deviceRepository = _factory.GetService<IDeviceRepository>();
|
||||||
_emergencyAccessRepository = _factory.GetService<IEmergencyAccessRepository>();
|
_emergencyAccessRepository = _factory.GetService<IEmergencyAccessRepository>();
|
||||||
_organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
|
_organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
|
||||||
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
|
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
|
||||||
@ -238,10 +240,12 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
|||||||
];
|
];
|
||||||
request.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey = _mockEncryptedString;
|
request.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey = _mockEncryptedString;
|
||||||
request.AccountUnlockData.PasskeyUnlockData = [];
|
request.AccountUnlockData.PasskeyUnlockData = [];
|
||||||
|
request.AccountUnlockData.DeviceKeyUnlockData = [];
|
||||||
request.AccountUnlockData.EmergencyAccessUnlockData = [];
|
request.AccountUnlockData.EmergencyAccessUnlockData = [];
|
||||||
request.AccountUnlockData.OrganizationAccountRecoveryUnlockData = [];
|
request.AccountUnlockData.OrganizationAccountRecoveryUnlockData = [];
|
||||||
|
|
||||||
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
|
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
|
||||||
|
var responseMessage = await response.Content.ReadAsStringAsync();
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
|
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
using Bit.Api.KeyManagement.Validators;
|
||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Api.Test.KeyManagement.Validators;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class DeviceRotationValidatorTests
|
||||||
|
{
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateAsync_SentDevicesAreEmptyButDatabaseDevicesAreNot_Throws(
|
||||||
|
SutProvider<DeviceRotationValidator> sutProvider, User user, IEnumerable<OtherDeviceKeysUpdateRequestModel> devices)
|
||||||
|
{
|
||||||
|
var userCiphers = devices.Select(c => new Device { Id = c.DeviceId, EncryptedPrivateKey = "EncryptedPrivateKey", EncryptedPublicKey = "EncryptedPublicKey", EncryptedUserKey = "EncryptedUserKey" }).ToList();
|
||||||
|
sutProvider.GetDependency<IDeviceRepository>().GetManyByUserIdAsync(user.Id)
|
||||||
|
.Returns(userCiphers);
|
||||||
|
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.ValidateAsync(user, Enumerable.Empty<OtherDeviceKeysUpdateRequestModel>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateAsync_SentDevicesTrustedButDatabaseUntrusted_Throws(
|
||||||
|
SutProvider<DeviceRotationValidator> sutProvider, User user, IEnumerable<OtherDeviceKeysUpdateRequestModel> devices)
|
||||||
|
{
|
||||||
|
var userCiphers = devices.Select(c => new Device { Id = c.DeviceId, EncryptedPrivateKey = "Key", EncryptedPublicKey = "Key", EncryptedUserKey = "Key" }).ToList();
|
||||||
|
sutProvider.GetDependency<IDeviceRepository>().GetManyByUserIdAsync(user.Id)
|
||||||
|
.Returns(userCiphers);
|
||||||
|
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.ValidateAsync(user, [
|
||||||
|
new OtherDeviceKeysUpdateRequestModel { DeviceId = userCiphers.First().Id, EncryptedPublicKey = null, EncryptedUserKey = null }
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateAsync_Validates(
|
||||||
|
SutProvider<DeviceRotationValidator> sutProvider, User user, IEnumerable<OtherDeviceKeysUpdateRequestModel> devices)
|
||||||
|
{
|
||||||
|
var userCiphers = devices.Select(c => new Device { Id = c.DeviceId, EncryptedPrivateKey = "Key", EncryptedPublicKey = "Key", EncryptedUserKey = "Key" }).ToList().Slice(0, 1);
|
||||||
|
sutProvider.GetDependency<IDeviceRepository>().GetManyByUserIdAsync(user.Id)
|
||||||
|
.Returns(userCiphers);
|
||||||
|
Assert.NotEmpty(await sutProvider.Sut.ValidateAsync(user, [
|
||||||
|
new OtherDeviceKeysUpdateRequestModel { DeviceId = userCiphers.First().Id, EncryptedPublicKey = "Key", EncryptedUserKey = "Key" }
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
@ -14,6 +14,59 @@ namespace Bit.Api.Test.KeyManagement.Validators;
|
|||||||
[SutProviderCustomize]
|
[SutProviderCustomize]
|
||||||
public class WebAuthnLoginKeyRotationValidatorTests
|
public class WebAuthnLoginKeyRotationValidatorTests
|
||||||
{
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task ValidateAsync_Succeeds_ReturnsValidCredentials(
|
||||||
|
SutProvider<WebAuthnLoginKeyRotationValidator> sutProvider, User user,
|
||||||
|
IEnumerable<WebAuthnLoginRotateKeyRequestModel> webauthnRotateCredentialData)
|
||||||
|
{
|
||||||
|
var guid = Guid.NewGuid();
|
||||||
|
|
||||||
|
var webauthnKeysToRotate = webauthnRotateCredentialData.Select(e => new WebAuthnLoginRotateKeyRequestModel
|
||||||
|
{
|
||||||
|
Id = guid,
|
||||||
|
EncryptedPublicKey = e.EncryptedPublicKey,
|
||||||
|
EncryptedUserKey = e.EncryptedUserKey
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var data = new WebAuthnCredential
|
||||||
|
{
|
||||||
|
Id = guid,
|
||||||
|
SupportsPrf = true,
|
||||||
|
EncryptedPublicKey = "TestKey",
|
||||||
|
EncryptedUserKey = "Test"
|
||||||
|
};
|
||||||
|
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id)
|
||||||
|
.Returns(new List<WebAuthnCredential> { data });
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(user, webauthnKeysToRotate);
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal(guid, result.First().Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task ValidateAsync_DoesNotSupportPRF_Ignores(
|
||||||
|
SutProvider<WebAuthnLoginKeyRotationValidator> sutProvider, User user,
|
||||||
|
IEnumerable<WebAuthnLoginRotateKeyRequestModel> webauthnRotateCredentialData)
|
||||||
|
{
|
||||||
|
var guid = Guid.NewGuid();
|
||||||
|
var webauthnKeysToRotate = webauthnRotateCredentialData.Select(e => new WebAuthnLoginRotateKeyRequestModel
|
||||||
|
{
|
||||||
|
Id = guid,
|
||||||
|
EncryptedUserKey = e.EncryptedUserKey,
|
||||||
|
EncryptedPublicKey = e.EncryptedPublicKey,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var data = new WebAuthnCredential { Id = guid, EncryptedUserKey = "Test", EncryptedPublicKey = "TestKey" };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id)
|
||||||
|
.Returns(new List<WebAuthnCredential> { data });
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(user, webauthnKeysToRotate);
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData]
|
[BitAutoData]
|
||||||
public async Task ValidateAsync_WrongWebAuthnKeys_Throws(
|
public async Task ValidateAsync_WrongWebAuthnKeys_Throws(
|
||||||
@ -30,6 +83,7 @@ public class WebAuthnLoginKeyRotationValidatorTests
|
|||||||
var data = new WebAuthnCredential
|
var data = new WebAuthnCredential
|
||||||
{
|
{
|
||||||
Id = Guid.Parse("00000000-0000-0000-0000-000000000002"),
|
Id = Guid.Parse("00000000-0000-0000-0000-000000000002"),
|
||||||
|
SupportsPrf = true,
|
||||||
EncryptedPublicKey = "TestKey",
|
EncryptedPublicKey = "TestKey",
|
||||||
EncryptedUserKey = "Test"
|
EncryptedUserKey = "Test"
|
||||||
};
|
};
|
||||||
@ -55,6 +109,7 @@ public class WebAuthnLoginKeyRotationValidatorTests
|
|||||||
var data = new WebAuthnCredential
|
var data = new WebAuthnCredential
|
||||||
{
|
{
|
||||||
Id = guid,
|
Id = guid,
|
||||||
|
SupportsPrf = true,
|
||||||
EncryptedPublicKey = "TestKey",
|
EncryptedPublicKey = "TestKey",
|
||||||
EncryptedUserKey = "Test"
|
EncryptedUserKey = "Test"
|
||||||
};
|
};
|
||||||
@ -81,6 +136,7 @@ public class WebAuthnLoginKeyRotationValidatorTests
|
|||||||
var data = new WebAuthnCredential
|
var data = new WebAuthnCredential
|
||||||
{
|
{
|
||||||
Id = guid,
|
Id = guid,
|
||||||
|
SupportsPrf = true,
|
||||||
EncryptedPublicKey = "TestKey",
|
EncryptedPublicKey = "TestKey",
|
||||||
EncryptedUserKey = "Test"
|
EncryptedUserKey = "Test"
|
||||||
};
|
};
|
||||||
|
@ -67,6 +67,7 @@ public class NotificationsControllerTests
|
|||||||
Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date);
|
Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date);
|
||||||
Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate);
|
Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate);
|
||||||
Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate);
|
Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate);
|
||||||
|
Assert.Equal(expectedNotificationStatusDetails.TaskId, notificationResponseModel.TaskId);
|
||||||
});
|
});
|
||||||
Assert.Null(listResponse.ContinuationToken);
|
Assert.Null(listResponse.ContinuationToken);
|
||||||
|
|
||||||
@ -116,6 +117,7 @@ public class NotificationsControllerTests
|
|||||||
Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date);
|
Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date);
|
||||||
Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate);
|
Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate);
|
||||||
Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate);
|
Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate);
|
||||||
|
Assert.Equal(expectedNotificationStatusDetails.TaskId, notificationResponseModel.TaskId);
|
||||||
});
|
});
|
||||||
Assert.Equal("2", listResponse.ContinuationToken);
|
Assert.Equal("2", listResponse.ContinuationToken);
|
||||||
|
|
||||||
@ -164,6 +166,7 @@ public class NotificationsControllerTests
|
|||||||
Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date);
|
Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date);
|
||||||
Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate);
|
Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate);
|
||||||
Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate);
|
Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate);
|
||||||
|
Assert.Equal(expectedNotificationStatusDetails.TaskId, notificationResponseModel.TaskId);
|
||||||
});
|
});
|
||||||
Assert.Null(listResponse.ContinuationToken);
|
Assert.Null(listResponse.ContinuationToken);
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ public class NotificationResponseModelTests
|
|||||||
ClientType = ClientType.All,
|
ClientType = ClientType.All,
|
||||||
Title = "Test Title",
|
Title = "Test Title",
|
||||||
Body = "Test Body",
|
Body = "Test Body",
|
||||||
|
TaskId = Guid.NewGuid(),
|
||||||
RevisionDate = DateTime.UtcNow - TimeSpan.FromMinutes(3),
|
RevisionDate = DateTime.UtcNow - TimeSpan.FromMinutes(3),
|
||||||
ReadDate = DateTime.UtcNow - TimeSpan.FromMinutes(1),
|
ReadDate = DateTime.UtcNow - TimeSpan.FromMinutes(1),
|
||||||
DeletedDate = DateTime.UtcNow,
|
DeletedDate = DateTime.UtcNow,
|
||||||
@ -39,5 +40,6 @@ public class NotificationResponseModelTests
|
|||||||
Assert.Equal(model.Date, notificationStatusDetails.RevisionDate);
|
Assert.Equal(model.Date, notificationStatusDetails.RevisionDate);
|
||||||
Assert.Equal(model.ReadDate, notificationStatusDetails.ReadDate);
|
Assert.Equal(model.ReadDate, notificationStatusDetails.ReadDate);
|
||||||
Assert.Equal(model.DeletedDate, notificationStatusDetails.DeletedDate);
|
Assert.Equal(model.DeletedDate, notificationStatusDetails.DeletedDate);
|
||||||
|
Assert.Equal(model.TaskId, notificationStatusDetails.TaskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
29
test/Core.IntegrationTest/Core.IntegrationTest.csproj
Normal file
29
test/Core.IntegrationTest/Core.IntegrationTest.csproj
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
|
<PackageReference Include="MartinCostello.Logging.XUnit" Version="0.5.1" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
|
<PackageReference Include="Rnwood.SmtpServer" Version="3.1.0-ci0868" />
|
||||||
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Core\Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
410
test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs
Normal file
410
test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Bit.Core.Models.Mail;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using MailKit.Security;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Rnwood.SmtpServer;
|
||||||
|
using Rnwood.SmtpServer.Extensions.Auth;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace Bit.Core.IntegrationTest;
|
||||||
|
|
||||||
|
public class MailKitSmtpMailDeliveryServiceTests
|
||||||
|
{
|
||||||
|
private readonly X509Certificate2 _selfSignedCert;
|
||||||
|
|
||||||
|
public MailKitSmtpMailDeliveryServiceTests(ITestOutputHelper testOutputHelper)
|
||||||
|
{
|
||||||
|
ConfigureSmtpServerLogging(testOutputHelper);
|
||||||
|
|
||||||
|
_selfSignedCert = CreateSelfSignedCert("localhost");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static X509Certificate2 CreateSelfSignedCert(string commonName)
|
||||||
|
{
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
var certRequest = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
return certRequest.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SaveCertAsync(string filePath, X509Certificate2 certificate)
|
||||||
|
{
|
||||||
|
await File.WriteAllBytesAsync(filePath, certificate.Export(X509ContentType.Cert));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureSmtpServerLogging(ITestOutputHelper testOutputHelper)
|
||||||
|
{
|
||||||
|
// Unfortunately this package doesn't public expose its logging infrastructure
|
||||||
|
// so we use private reflection to try and access it.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loggingType = typeof(DefaultServerBehaviour).Assembly.GetType("Rnwood.SmtpServer.Logging")
|
||||||
|
?? throw new Exception("No type found in RnWood.SmtpServer named 'Logging'");
|
||||||
|
|
||||||
|
var factoryProperty = loggingType.GetProperty("Factory")
|
||||||
|
?? throw new Exception($"No property named 'Factory' found on class {loggingType.FullName}");
|
||||||
|
|
||||||
|
var factoryPropertyGet = factoryProperty.GetMethod
|
||||||
|
?? throw new Exception($"{loggingType.FullName}.{factoryProperty.Name} does not have a get method.");
|
||||||
|
|
||||||
|
if (factoryPropertyGet.Invoke(null, null) is not ILoggerFactory loggerFactory)
|
||||||
|
{
|
||||||
|
throw new Exception($"{loggingType.FullName}.{factoryProperty.Name} is not of type 'ILoggerFactory'" +
|
||||||
|
$"instead it's type '{factoryProperty.PropertyType.FullName}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
loggerFactory.AddXUnit(testOutputHelper);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
testOutputHelper.WriteLine($"Failed to configure logging for RnWood.SmtpServer (logging will not be configured):\n{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static int RandomPort()
|
||||||
|
{
|
||||||
|
return Random.Shared.Next(50000, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GlobalSettings GetSettings(Action<GlobalSettings> configure)
|
||||||
|
{
|
||||||
|
var globalSettings = new GlobalSettings();
|
||||||
|
globalSettings.SiteName = "TestSiteName";
|
||||||
|
globalSettings.Mail.ReplyToEmail = "test@example.com";
|
||||||
|
globalSettings.Mail.Smtp.Host = "localhost";
|
||||||
|
// Set common defaults
|
||||||
|
configure(globalSettings);
|
||||||
|
return globalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertNotInTrustedRootStore_ThrowsException()
|
||||||
|
{
|
||||||
|
// If an SMTP server is using a self signed cert we currently require
|
||||||
|
// that the certificate for their SMTP server is installed in the root CA
|
||||||
|
// we are building the ability to do so without installing it, when we add that
|
||||||
|
// this test can be copied, and changed to utilize that new feature and instead of
|
||||||
|
// failing it should successfully send the email.
|
||||||
|
var port = RandomPort();
|
||||||
|
var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
|
||||||
|
using var smtpServer = new SmtpServer(behavior);
|
||||||
|
smtpServer.Start();
|
||||||
|
|
||||||
|
var globalSettings = GetSettings(gs =>
|
||||||
|
{
|
||||||
|
gs.Mail.Smtp.Port = port;
|
||||||
|
gs.Mail.Smtp.Ssl = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
|
||||||
|
globalSettings,
|
||||||
|
NullLogger<MailKitSmtpMailDeliveryService>.Instance
|
||||||
|
);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<SslHandshakeException>(
|
||||||
|
async () => await mailKitDeliveryService.SendEmailAsync(new MailMessage
|
||||||
|
{
|
||||||
|
Subject = "Test",
|
||||||
|
ToEmails = ["test@example.com"],
|
||||||
|
TextContent = "Hi",
|
||||||
|
}, new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Upcoming feature")]
|
||||||
|
public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_Works()
|
||||||
|
{
|
||||||
|
// If an SMTP server is using a self signed cert we will in the future
|
||||||
|
// allow a custom location for certificates to be stored and the certitifactes
|
||||||
|
// stored there will also be trusted.
|
||||||
|
var port = RandomPort();
|
||||||
|
var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
|
||||||
|
using var smtpServer = new SmtpServer(behavior);
|
||||||
|
smtpServer.Start();
|
||||||
|
|
||||||
|
var globalSettings = GetSettings(gs =>
|
||||||
|
{
|
||||||
|
gs.Mail.Smtp.Port = port;
|
||||||
|
gs.Mail.Smtp.Ssl = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Setup custom location and save self signed cert there.
|
||||||
|
// await SaveCertAsync("./my-location", _selfSignedCert);
|
||||||
|
|
||||||
|
var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
|
||||||
|
globalSettings,
|
||||||
|
NullLogger<MailKitSmtpMailDeliveryService>.Instance
|
||||||
|
);
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource();
|
||||||
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
cts.Token.Register(() => _ = tcs.TrySetCanceled());
|
||||||
|
|
||||||
|
behavior.MessageReceivedEventHandler += (sender, args) =>
|
||||||
|
{
|
||||||
|
if (args.Message.Recipients.Contains("test1@example.com"))
|
||||||
|
{
|
||||||
|
tcs.SetResult();
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailKitDeliveryService.SendEmailAsync(new MailMessage
|
||||||
|
{
|
||||||
|
Subject = "Test",
|
||||||
|
ToEmails = ["test1@example.com"],
|
||||||
|
TextContent = "Hi",
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
// Wait for email
|
||||||
|
await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Upcoming feature")]
|
||||||
|
public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_WithUnrelatedCerts_Works()
|
||||||
|
{
|
||||||
|
// If an SMTP server is using a self signed cert we will in the future
|
||||||
|
// allow a custom location for certificates to be stored and the certitifactes
|
||||||
|
// stored there will also be trusted.
|
||||||
|
var port = RandomPort();
|
||||||
|
var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
|
||||||
|
using var smtpServer = new SmtpServer(behavior);
|
||||||
|
smtpServer.Start();
|
||||||
|
|
||||||
|
var globalSettings = GetSettings(gs =>
|
||||||
|
{
|
||||||
|
gs.Mail.Smtp.Port = port;
|
||||||
|
gs.Mail.Smtp.Ssl = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Setup custom location and save self signed cert there
|
||||||
|
// along with another self signed cert that is not related to
|
||||||
|
// the SMTP server.
|
||||||
|
// await SaveCertAsync("./my-location", _selfSignedCert);
|
||||||
|
// await SaveCertAsync("./my-location", CreateSelfSignedCert("example.com"));
|
||||||
|
|
||||||
|
var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
|
||||||
|
globalSettings,
|
||||||
|
NullLogger<MailKitSmtpMailDeliveryService>.Instance
|
||||||
|
);
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource();
|
||||||
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
cts.Token.Register(() => _ = tcs.TrySetCanceled());
|
||||||
|
|
||||||
|
behavior.MessageReceivedEventHandler += (sender, args) =>
|
||||||
|
{
|
||||||
|
if (args.Message.Recipients.Contains("test1@example.com"))
|
||||||
|
{
|
||||||
|
tcs.SetResult();
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailKitDeliveryService.SendEmailAsync(new MailMessage
|
||||||
|
{
|
||||||
|
Subject = "Test",
|
||||||
|
ToEmails = ["test1@example.com"],
|
||||||
|
TextContent = "Hi",
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
// Wait for email
|
||||||
|
await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_Succeeds_WhenCertIsSelfSigned_ServerIsTrusted()
|
||||||
|
{
|
||||||
|
// When the setting `TrustServer = true` is set even if the cert is
|
||||||
|
// self signed and the cert is not trusted in anyway the connection should
|
||||||
|
// still go through.
|
||||||
|
var port = RandomPort();
|
||||||
|
var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
|
||||||
|
using var smtpServer = new SmtpServer(behavior);
|
||||||
|
smtpServer.Start();
|
||||||
|
|
||||||
|
var globalSettings = GetSettings(gs =>
|
||||||
|
{
|
||||||
|
gs.Mail.Smtp.Port = port;
|
||||||
|
gs.Mail.Smtp.Ssl = true;
|
||||||
|
gs.Mail.Smtp.TrustServer = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
|
||||||
|
globalSettings,
|
||||||
|
NullLogger<MailKitSmtpMailDeliveryService>.Instance
|
||||||
|
);
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource();
|
||||||
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
cts.Token.Register(() => _ = tcs.TrySetCanceled());
|
||||||
|
|
||||||
|
behavior.MessageReceivedEventHandler += (sender, args) =>
|
||||||
|
{
|
||||||
|
if (args.Message.Recipients.Contains("test1@example.com"))
|
||||||
|
{
|
||||||
|
tcs.SetResult();
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailKitDeliveryService.SendEmailAsync(new MailMessage
|
||||||
|
{
|
||||||
|
Subject = "Test",
|
||||||
|
ToEmails = ["test1@example.com"],
|
||||||
|
TextContent = "Hi",
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
// Wait for email
|
||||||
|
await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_FailsConnectingWithTls_ServerDoesNotSupportTls()
|
||||||
|
{
|
||||||
|
// If the SMTP server is not setup to use TLS but our server is expecting it
|
||||||
|
// to, we should fail.
|
||||||
|
var port = RandomPort();
|
||||||
|
var behavior = new DefaultServerBehaviour(false, port);
|
||||||
|
using var smtpServer = new SmtpServer(behavior);
|
||||||
|
smtpServer.Start();
|
||||||
|
|
||||||
|
var globalSettings = GetSettings(gs =>
|
||||||
|
{
|
||||||
|
gs.Mail.Smtp.Port = port;
|
||||||
|
gs.Mail.Smtp.Ssl = true;
|
||||||
|
gs.Mail.Smtp.TrustServer = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
|
||||||
|
globalSettings,
|
||||||
|
NullLogger<MailKitSmtpMailDeliveryService>.Instance
|
||||||
|
);
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<SslHandshakeException>(
|
||||||
|
async () => await mailKitDeliveryService.SendEmailAsync(new MailMessage
|
||||||
|
{
|
||||||
|
Subject = "Test",
|
||||||
|
ToEmails = ["test1@example.com"],
|
||||||
|
TextContent = "Hi",
|
||||||
|
}, cts.Token)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires permission to privileged port")]
|
||||||
|
public async Task SendEmailAsync_Works_NoSsl()
|
||||||
|
{
|
||||||
|
// If the SMTP server isn't set up with any SSL/TLS and we dont' expect
|
||||||
|
// any, then the email should go through just fine. Just without encryption.
|
||||||
|
// This test has to use port 25
|
||||||
|
var port = 25;
|
||||||
|
var behavior = new DefaultServerBehaviour(false, port);
|
||||||
|
using var smtpServer = new SmtpServer(behavior);
|
||||||
|
smtpServer.Start();
|
||||||
|
|
||||||
|
var globalSettings = GetSettings(gs =>
|
||||||
|
{
|
||||||
|
gs.Mail.Smtp.Port = port;
|
||||||
|
gs.Mail.Smtp.Ssl = false;
|
||||||
|
gs.Mail.Smtp.StartTls = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
|
||||||
|
globalSettings,
|
||||||
|
NullLogger<MailKitSmtpMailDeliveryService>.Instance
|
||||||
|
);
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource();
|
||||||
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
cts.Token.Register(() => _ = tcs.TrySetCanceled());
|
||||||
|
|
||||||
|
behavior.MessageReceivedEventHandler += (sender, args) =>
|
||||||
|
{
|
||||||
|
if (args.Message.Recipients.Contains("test1@example.com"))
|
||||||
|
{
|
||||||
|
tcs.SetResult();
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailKitDeliveryService.SendEmailAsync(new MailMessage
|
||||||
|
{
|
||||||
|
Subject = "Test",
|
||||||
|
ToEmails = ["test1@example.com"],
|
||||||
|
TextContent = "Hi",
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
// Wait for email
|
||||||
|
await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_Succeeds_WhenServerNeedsToAuthenticate()
|
||||||
|
{
|
||||||
|
// When the setting `TrustServer = true` is set even if the cert is
|
||||||
|
// self signed and the cert is not trusted in anyway the connection should
|
||||||
|
// still go through.
|
||||||
|
var port = RandomPort();
|
||||||
|
var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
|
||||||
|
behavior.AuthenticationCredentialsValidationRequiredEventHandler += (sender, args) =>
|
||||||
|
{
|
||||||
|
args.AuthenticationResult = AuthenticationResult.Failure;
|
||||||
|
if (args.Credentials is not UsernameAndPasswordAuthenticationCredentials usernameAndPasswordCreds)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usernameAndPasswordCreds.Username != "test" || usernameAndPasswordCreds.Password != "password")
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
args.AuthenticationResult = AuthenticationResult.Success;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
using var smtpServer = new SmtpServer(behavior);
|
||||||
|
smtpServer.Start();
|
||||||
|
|
||||||
|
var globalSettings = GetSettings(gs =>
|
||||||
|
{
|
||||||
|
gs.Mail.Smtp.Port = port;
|
||||||
|
gs.Mail.Smtp.Ssl = true;
|
||||||
|
gs.Mail.Smtp.TrustServer = true;
|
||||||
|
|
||||||
|
gs.Mail.Smtp.Username = "test";
|
||||||
|
gs.Mail.Smtp.Password = "password";
|
||||||
|
});
|
||||||
|
|
||||||
|
var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
|
||||||
|
globalSettings,
|
||||||
|
NullLogger<MailKitSmtpMailDeliveryService>.Instance
|
||||||
|
);
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource();
|
||||||
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
cts.Token.Register(() => _ = tcs.TrySetCanceled());
|
||||||
|
|
||||||
|
behavior.MessageReceivedEventHandler += (sender, args) =>
|
||||||
|
{
|
||||||
|
if (args.Message.Recipients.Contains("test1@example.com"))
|
||||||
|
{
|
||||||
|
tcs.SetResult();
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailKitDeliveryService.SendEmailAsync(new MailMessage
|
||||||
|
{
|
||||||
|
Subject = "Test",
|
||||||
|
ToEmails = ["test1@example.com"],
|
||||||
|
TextContent = "Hi",
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
// Wait for email
|
||||||
|
await tcs.Task;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Bit.Core.Models.Data.Organizations;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Models.Data.Organizations;
|
||||||
|
|
||||||
|
public class OrganizationIntegrationConfigurationDetailsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MergedConfiguration_WithValidConfigAndIntegration_ReturnsMergedJson()
|
||||||
|
{
|
||||||
|
var config = new { config = "A new config value" };
|
||||||
|
var integration = new { integration = "An integration value" };
|
||||||
|
var expectedObj = new { integration = "An integration value", config = "A new config value" };
|
||||||
|
var expected = JsonSerializer.Serialize(expectedObj);
|
||||||
|
|
||||||
|
var sut = new OrganizationIntegrationConfigurationDetails();
|
||||||
|
sut.Configuration = JsonSerializer.Serialize(config);
|
||||||
|
sut.IntegrationConfiguration = JsonSerializer.Serialize(integration);
|
||||||
|
|
||||||
|
var result = sut.MergedConfiguration;
|
||||||
|
Assert.Equal(expected, result.ToJsonString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MergedConfiguration_WithInvalidJsonConfigAndIntegration_ReturnsEmptyJson()
|
||||||
|
{
|
||||||
|
var expectedObj = new { };
|
||||||
|
var expected = JsonSerializer.Serialize(expectedObj);
|
||||||
|
|
||||||
|
var sut = new OrganizationIntegrationConfigurationDetails();
|
||||||
|
sut.Configuration = "Not JSON";
|
||||||
|
sut.IntegrationConfiguration = "Not JSON";
|
||||||
|
|
||||||
|
var result = sut.MergedConfiguration;
|
||||||
|
Assert.Equal(expected, result.ToJsonString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MergedConfiguration_WithNullConfigAndIntegration_ReturnsEmptyJson()
|
||||||
|
{
|
||||||
|
var expectedObj = new { };
|
||||||
|
var expected = JsonSerializer.Serialize(expectedObj);
|
||||||
|
|
||||||
|
var sut = new OrganizationIntegrationConfigurationDetails();
|
||||||
|
sut.Configuration = null;
|
||||||
|
sut.IntegrationConfiguration = null;
|
||||||
|
|
||||||
|
var result = sut.MergedConfiguration;
|
||||||
|
Assert.Equal(expected, result.ToJsonString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MergedConfiguration_WithValidIntegrationAndNullConfig_ReturnsIntegrationJson()
|
||||||
|
{
|
||||||
|
var integration = new { integration = "An integration value" };
|
||||||
|
var expectedObj = new { integration = "An integration value" };
|
||||||
|
var expected = JsonSerializer.Serialize(expectedObj);
|
||||||
|
|
||||||
|
var sut = new OrganizationIntegrationConfigurationDetails();
|
||||||
|
sut.Configuration = null;
|
||||||
|
sut.IntegrationConfiguration = JsonSerializer.Serialize(integration);
|
||||||
|
|
||||||
|
var result = sut.MergedConfiguration;
|
||||||
|
Assert.Equal(expected, result.ToJsonString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MergedConfiguration_WithValidConfigAndNullIntegration_ReturnsConfigJson()
|
||||||
|
{
|
||||||
|
var config = new { config = "A new config value" };
|
||||||
|
var expectedObj = new { config = "A new config value" };
|
||||||
|
var expected = JsonSerializer.Serialize(expectedObj);
|
||||||
|
|
||||||
|
var sut = new OrganizationIntegrationConfigurationDetails();
|
||||||
|
sut.Configuration = JsonSerializer.Serialize(config);
|
||||||
|
sut.IntegrationConfiguration = null;
|
||||||
|
|
||||||
|
var result = sut.MergedConfiguration;
|
||||||
|
Assert.Equal(expected, result.ToJsonString());
|
||||||
|
}
|
||||||
|
}
|
@ -131,6 +131,38 @@ public class DeleteManagedOrganizationUserAccountCommandTests
|
|||||||
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
|
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task DeleteUserAsync_WhenCustomUserDeletesAdmin_ThrowsException(
|
||||||
|
SutProvider<DeleteManagedOrganizationUserAccountCommand> sutProvider, User user,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser,
|
||||||
|
Guid deletingUserId)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
organizationUser.UserId = user.Id;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetByIdAsync(organizationUser.Id)
|
||||||
|
.Returns(organizationUser);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id)
|
||||||
|
.Returns(user);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>()
|
||||||
|
.OrganizationCustom(organizationUser.OrganizationId)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
|
sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("Custom users can not delete admins.", exception.Message);
|
||||||
|
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
|
||||||
|
await sutProvider.GetDependency<IEventService>().Received(0)
|
||||||
|
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData]
|
[BitAutoData]
|
||||||
public async Task DeleteUserAsync_DeletingOwnerWhenNotOwner_ThrowsException(
|
public async Task DeleteUserAsync_DeletingOwnerWhenNotOwner_ThrowsException(
|
||||||
|
@ -171,6 +171,28 @@ public class RemoveOrganizationUserCommandTests
|
|||||||
Assert.Contains(RemoveOrganizationUserCommand.RemoveOwnerByNonOwnerErrorMessage, exception.Message);
|
Assert.Contains(RemoveOrganizationUserCommand.RemoveOwnerByNonOwnerErrorMessage, exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task RemoveUser_WhenCustomUserRemovesAdmin_ThrowsException(
|
||||||
|
[OrganizationUser(type: OrganizationUserType.Admin)] OrganizationUser organizationUser,
|
||||||
|
[OrganizationUser(type: OrganizationUserType.Custom)] OrganizationUser deletingUser,
|
||||||
|
SutProvider<RemoveOrganizationUserCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
organizationUser.OrganizationId = deletingUser.OrganizationId;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetByIdAsync(organizationUser.Id)
|
||||||
|
.Returns(organizationUser);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>()
|
||||||
|
.OrganizationCustom(organizationUser.OrganizationId)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
|
() => sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUser.UserId));
|
||||||
|
Assert.Contains(RemoveOrganizationUserCommand.RemoveAdminByCustomUserErrorMessage, exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task RemoveUser_WithDeletingUserId_RemovingLastOwner_ThrowsException(
|
public async Task RemoveUser_WithDeletingUserId_RemovingLastOwner_ThrowsException(
|
||||||
[OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser organizationUser,
|
[OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser organizationUser,
|
||||||
|
@ -471,10 +471,11 @@ public class RestoreOrganizationUserCommandTests
|
|||||||
Organization organization,
|
Organization organization,
|
||||||
Organization otherOrganization,
|
Organization otherOrganization,
|
||||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||||
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser,
|
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.Owner)] OrganizationUser organizationUser,
|
||||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserOwnerFromDifferentOrg,
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserOwnerFromDifferentOrg,
|
||||||
SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
||||||
{
|
{
|
||||||
|
organization.PlanType = PlanType.Free;
|
||||||
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
|
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
|
||||||
|
|
||||||
orgUserOwnerFromDifferentOrg.UserId = organizationUser.UserId;
|
orgUserOwnerFromDifferentOrg.UserId = organizationUser.UserId;
|
||||||
@ -506,6 +507,107 @@ public class RestoreOrganizationUserCommandTests
|
|||||||
Assert.Equal("User is an owner/admin of another free organization. Please have them upgrade to a paid plan to restore their account.", exception.Message);
|
Assert.Equal("User is an owner/admin of another free organization. Please have them upgrade to a paid plan to restore their account.", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task RestoreUser_WhenUserOwningAnotherFreeOrganizationAndIsOnlyAUserInCurrentOrg_ThenUserShouldBeRestored(
|
||||||
|
Organization organization,
|
||||||
|
Organization otherOrganization,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserOwnerFromDifferentOrg,
|
||||||
|
SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
||||||
|
{
|
||||||
|
organization.PlanType = PlanType.Free;
|
||||||
|
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
|
||||||
|
|
||||||
|
orgUserOwnerFromDifferentOrg.UserId = organizationUser.UserId;
|
||||||
|
otherOrganization.Id = orgUserOwnerFromDifferentOrg.OrganizationId;
|
||||||
|
otherOrganization.PlanType = PlanType.Free;
|
||||||
|
|
||||||
|
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
|
||||||
|
|
||||||
|
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||||
|
organizationUserRepository
|
||||||
|
.GetManyByUserAsync(organizationUser.UserId.Value)
|
||||||
|
.Returns([orgUserOwnerFromDifferentOrg]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetManyByUserIdAsync(organizationUser.UserId.Value)
|
||||||
|
.Returns([otherOrganization]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IPolicyService>()
|
||||||
|
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication,
|
||||||
|
Arg.Any<OrganizationUserStatusType>())
|
||||||
|
.Returns([
|
||||||
|
new OrganizationUserPolicyDetails
|
||||||
|
{
|
||||||
|
OrganizationId = organizationUser.OrganizationId,
|
||||||
|
PolicyType = PolicyType.TwoFactorAuthentication
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||||
|
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(i => i.Contains(organizationUser.UserId.Value)))
|
||||||
|
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) });
|
||||||
|
|
||||||
|
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
|
||||||
|
|
||||||
|
await organizationUserRepository
|
||||||
|
.Received(1)
|
||||||
|
.RestoreAsync(organizationUser.Id,
|
||||||
|
Arg.Is<OrganizationUserStatusType>(x => x != OrganizationUserStatusType.Revoked));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task RestoreUser_WhenUserOwningAnotherFreeOrganizationAndCurrentOrgIsNotFree_ThenUserShouldBeRestored(
|
||||||
|
Organization organization,
|
||||||
|
Organization otherOrganization,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.Owner)] OrganizationUser organizationUser,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserOwnerFromDifferentOrg,
|
||||||
|
SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
||||||
|
{
|
||||||
|
organization.PlanType = PlanType.EnterpriseAnnually2023;
|
||||||
|
|
||||||
|
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
|
||||||
|
|
||||||
|
orgUserOwnerFromDifferentOrg.UserId = organizationUser.UserId;
|
||||||
|
otherOrganization.Id = orgUserOwnerFromDifferentOrg.OrganizationId;
|
||||||
|
otherOrganization.PlanType = PlanType.Free;
|
||||||
|
|
||||||
|
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
|
||||||
|
|
||||||
|
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||||
|
organizationUserRepository
|
||||||
|
.GetManyByUserAsync(organizationUser.UserId.Value)
|
||||||
|
.Returns([orgUserOwnerFromDifferentOrg]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetManyByUserIdAsync(organizationUser.UserId.Value)
|
||||||
|
.Returns([otherOrganization]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IPolicyService>()
|
||||||
|
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication,
|
||||||
|
Arg.Any<OrganizationUserStatusType>())
|
||||||
|
.Returns([
|
||||||
|
new OrganizationUserPolicyDetails
|
||||||
|
{
|
||||||
|
OrganizationId = organizationUser.OrganizationId,
|
||||||
|
PolicyType = PolicyType.TwoFactorAuthentication
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||||
|
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(i => i.Contains(organizationUser.UserId.Value)))
|
||||||
|
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) });
|
||||||
|
|
||||||
|
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
|
||||||
|
|
||||||
|
await organizationUserRepository
|
||||||
|
.Received(1)
|
||||||
|
.RestoreAsync(organizationUser.Id,
|
||||||
|
Arg.Is<OrganizationUserStatusType>(x => x != OrganizationUserStatusType.Revoked));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task RestoreUsers_Success(Organization organization,
|
public async Task RestoreUsers_Success(Organization organization,
|
||||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||||
@ -612,7 +714,7 @@ public class RestoreOrganizationUserCommandTests
|
|||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task RestoreUsers_UserOwnsAnotherFreeOrganization_BlocksOwnerUserFromBeingRestored(Organization organization,
|
public async Task RestoreUsers_UserOwnsAnotherFreeOrganization_BlocksOwnerUserFromBeingRestored(Organization organization,
|
||||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||||
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1,
|
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.Owner)] OrganizationUser orgUser1,
|
||||||
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2,
|
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2,
|
||||||
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser3,
|
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser3,
|
||||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserFromOtherOrg,
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserFromOtherOrg,
|
||||||
@ -637,7 +739,7 @@ public class RestoreOrganizationUserCommandTests
|
|||||||
|
|
||||||
organizationUserRepository
|
organizationUserRepository
|
||||||
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id)))
|
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id)))
|
||||||
.Returns(new[] { orgUser1, orgUser2, orgUser3 });
|
.Returns([orgUser1, orgUser2, orgUser3]);
|
||||||
|
|
||||||
userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" });
|
userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" });
|
||||||
|
|
||||||
@ -674,6 +776,110 @@ public class RestoreOrganizationUserCommandTests
|
|||||||
.RestoreAsync(orgUser1.Id, OrganizationUserStatusType.Confirmed);
|
.RestoreAsync(orgUser1.Id, OrganizationUserStatusType.Confirmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task RestoreUsers_UserOwnsAnotherFreeOrganizationButReactivatingOrgIsPaid_RestoresUser(Organization organization,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.Owner)] OrganizationUser orgUser1,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserFromOtherOrg,
|
||||||
|
Organization otherOrganization,
|
||||||
|
SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
organization.PlanType = PlanType.EnterpriseAnnually2023;
|
||||||
|
|
||||||
|
RestoreUser_Setup(organization, owner, orgUser1, sutProvider);
|
||||||
|
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||||
|
var policyService = sutProvider.GetDependency<IPolicyService>();
|
||||||
|
var userService = Substitute.For<IUserService>();
|
||||||
|
|
||||||
|
orgUser1.OrganizationId = organization.Id;
|
||||||
|
|
||||||
|
orgUserFromOtherOrg.UserId = orgUser1.UserId;
|
||||||
|
|
||||||
|
otherOrganization.Id = orgUserFromOtherOrg.OrganizationId;
|
||||||
|
otherOrganization.PlanType = PlanType.Free;
|
||||||
|
|
||||||
|
organizationUserRepository
|
||||||
|
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id)))
|
||||||
|
.Returns([orgUser1]);
|
||||||
|
|
||||||
|
organizationUserRepository
|
||||||
|
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||||
|
.Returns([orgUserFromOtherOrg]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetManyByIdsAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUserFromOtherOrg.OrganizationId)))
|
||||||
|
.Returns([otherOrganization]);
|
||||||
|
|
||||||
|
|
||||||
|
// Setup 2FA policy
|
||||||
|
policyService.GetPoliciesApplicableToUserAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication, Arg.Any<OrganizationUserStatusType>())
|
||||||
|
.Returns([new OrganizationUserPolicyDetails { OrganizationId = organization.Id, PolicyType = PolicyType.TwoFactorAuthentication }]);
|
||||||
|
|
||||||
|
// User1 has 2FA, User2 doesn't
|
||||||
|
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||||
|
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.UserId!.Value)))
|
||||||
|
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)>
|
||||||
|
{
|
||||||
|
(orgUser1.UserId!.Value, true)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal(string.Empty, result[0].Item2);
|
||||||
|
await organizationUserRepository
|
||||||
|
.Received(1)
|
||||||
|
.RestoreAsync(orgUser1.Id, Arg.Is<OrganizationUserStatusType>(x => x != OrganizationUserStatusType.Revoked));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task RestoreUsers_UserOwnsAnotherOrganizationButIsOnlyUserOfCurrentOrganization_UserShouldBeRestored(
|
||||||
|
Organization organization,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser orgUser1,
|
||||||
|
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserFromOtherOrg,
|
||||||
|
Organization otherOrganization,
|
||||||
|
SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
organization.PlanType = PlanType.Free;
|
||||||
|
|
||||||
|
RestoreUser_Setup(organization, owner, orgUser1, sutProvider);
|
||||||
|
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||||
|
var userService = Substitute.For<IUserService>();
|
||||||
|
|
||||||
|
orgUser1.OrganizationId = organization.Id;
|
||||||
|
|
||||||
|
orgUserFromOtherOrg.UserId = orgUser1.UserId;
|
||||||
|
|
||||||
|
otherOrganization.Id = orgUserFromOtherOrg.OrganizationId;
|
||||||
|
otherOrganization.PlanType = PlanType.Free;
|
||||||
|
|
||||||
|
organizationUserRepository
|
||||||
|
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id)))
|
||||||
|
.Returns([orgUser1]);
|
||||||
|
|
||||||
|
organizationUserRepository
|
||||||
|
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||||
|
.Returns([orgUserFromOtherOrg]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IPolicyService>().GetPoliciesApplicableToUserAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication, Arg.Any<OrganizationUserStatusType>())
|
||||||
|
.Returns([new OrganizationUserPolicyDetails { OrganizationId = organization.Id, PolicyType = PolicyType.TwoFactorAuthentication }]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService);
|
||||||
|
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal(string.Empty, result[0].Item2);
|
||||||
|
await organizationUserRepository
|
||||||
|
.Received(1)
|
||||||
|
.RestoreAsync(orgUser1.Id, Arg.Is<OrganizationUserStatusType>(x => x != OrganizationUserStatusType.Revoked));
|
||||||
|
}
|
||||||
|
|
||||||
private static void RestoreUser_Setup(
|
private static void RestoreUser_Setup(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
OrganizationUser? requestingOrganizationUser,
|
OrganizationUser? requestingOrganizationUser,
|
||||||
|
@ -17,6 +17,7 @@ using Bit.Core.Utilities;
|
|||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using Bit.Test.Common.Helpers;
|
using Bit.Test.Common.Helpers;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||||
@ -273,78 +274,7 @@ public class AuthRequestServiceTests
|
|||||||
/// each of them.
|
/// each of them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task CreateAuthRequestAsync_AdminApproval_CreatesForEachOrganization(
|
public async Task CreateAuthRequestAsync_AdminApproval_CreatesForEachOrganization_SendsEmails(
|
||||||
SutProvider<AuthRequestService> sutProvider,
|
|
||||||
AuthRequestCreateRequestModel createModel,
|
|
||||||
User user,
|
|
||||||
OrganizationUser organizationUser1,
|
|
||||||
OrganizationUser organizationUser2)
|
|
||||||
{
|
|
||||||
createModel.Type = AuthRequestType.AdminApproval;
|
|
||||||
user.Email = createModel.Email;
|
|
||||||
organizationUser1.UserId = user.Id;
|
|
||||||
organizationUser2.UserId = user.Id;
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IUserRepository>()
|
|
||||||
.GetByEmailAsync(user.Email)
|
|
||||||
.Returns(user);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<ICurrentContext>()
|
|
||||||
.DeviceType
|
|
||||||
.Returns(DeviceType.ChromeExtension);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<ICurrentContext>()
|
|
||||||
.UserId
|
|
||||||
.Returns(user.Id);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IGlobalSettings>()
|
|
||||||
.PasswordlessAuth.KnownDevicesOnly
|
|
||||||
.Returns(false);
|
|
||||||
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
|
||||||
.GetManyByUserAsync(user.Id)
|
|
||||||
.Returns(new List<OrganizationUser>
|
|
||||||
{
|
|
||||||
organizationUser1,
|
|
||||||
organizationUser2,
|
|
||||||
});
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
|
||||||
.CreateAsync(Arg.Any<AuthRequest>())
|
|
||||||
.Returns(c => c.ArgAt<AuthRequest>(0));
|
|
||||||
|
|
||||||
var authRequest = await sutProvider.Sut.CreateAuthRequestAsync(createModel);
|
|
||||||
|
|
||||||
Assert.Equal(organizationUser1.OrganizationId, authRequest.OrganizationId);
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IAuthRequestRepository>()
|
|
||||||
.Received(1)
|
|
||||||
.CreateAsync(Arg.Is<AuthRequest>(o => o.OrganizationId == organizationUser1.OrganizationId));
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IAuthRequestRepository>()
|
|
||||||
.Received(1)
|
|
||||||
.CreateAsync(Arg.Is<AuthRequest>(o => o.OrganizationId == organizationUser2.OrganizationId));
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IAuthRequestRepository>()
|
|
||||||
.Received(2)
|
|
||||||
.CreateAsync(Arg.Any<AuthRequest>());
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IEventService>()
|
|
||||||
.Received(1)
|
|
||||||
.LogUserEventAsync(user.Id, EventType.User_RequestedDeviceApproval);
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>()
|
|
||||||
.DidNotReceiveWithAnyArgs()
|
|
||||||
.SendDeviceApprovalRequestedNotificationEmailAsync(
|
|
||||||
Arg.Any<IEnumerable<string>>(),
|
|
||||||
Arg.Any<Guid>(),
|
|
||||||
Arg.Any<string>(),
|
|
||||||
Arg.Any<string>());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public async Task CreateAuthRequestAsync_AdminApproval_WithAdminNotifications_CreatesForEachOrganization_SendsEmails(
|
|
||||||
SutProvider<AuthRequestService> sutProvider,
|
SutProvider<AuthRequestService> sutProvider,
|
||||||
AuthRequestCreateRequestModel createModel,
|
AuthRequestCreateRequestModel createModel,
|
||||||
User user,
|
User user,
|
||||||
@ -369,10 +299,6 @@ public class AuthRequestServiceTests
|
|||||||
ManageResetPassword = true,
|
ManageResetPassword = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
sutProvider.GetDependency<IFeatureService>()
|
|
||||||
.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications)
|
|
||||||
.Returns(true);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IUserRepository>()
|
sutProvider.GetDependency<IUserRepository>()
|
||||||
.GetByEmailAsync(user.Email)
|
.GetByEmailAsync(user.Email)
|
||||||
.Returns(user);
|
.Returns(user);
|
||||||
@ -470,6 +396,87 @@ public class AuthRequestServiceTests
|
|||||||
user.Name);
|
user.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CreateAuthRequestAsync_AdminApproval_WithAdminNotifications_AndNoAdminEmails_ShouldNotSendNotificationEmails(
|
||||||
|
SutProvider<AuthRequestService> sutProvider,
|
||||||
|
AuthRequestCreateRequestModel createModel,
|
||||||
|
User user,
|
||||||
|
OrganizationUser organizationUser1)
|
||||||
|
{
|
||||||
|
createModel.Type = AuthRequestType.AdminApproval;
|
||||||
|
user.Email = createModel.Email;
|
||||||
|
organizationUser1.UserId = user.Id;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUserRepository>()
|
||||||
|
.GetByEmailAsync(user.Email)
|
||||||
|
.Returns(user);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>()
|
||||||
|
.DeviceType
|
||||||
|
.Returns(DeviceType.ChromeExtension);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>()
|
||||||
|
.UserId
|
||||||
|
.Returns(user.Id);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IGlobalSettings>()
|
||||||
|
.PasswordlessAuth.KnownDevicesOnly
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetManyByUserAsync(user.Id)
|
||||||
|
.Returns(new List<OrganizationUser>
|
||||||
|
{
|
||||||
|
organizationUser1,
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetManyByMinimumRoleAsync(organizationUser1.OrganizationId, OrganizationUserType.Admin)
|
||||||
|
.Returns([]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetManyDetailsByRoleAsync(organizationUser1.OrganizationId, OrganizationUserType.Custom)
|
||||||
|
.Returns([]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||||
|
.CreateAsync(Arg.Any<AuthRequest>())
|
||||||
|
.Returns(c => c.ArgAt<AuthRequest>(0));
|
||||||
|
|
||||||
|
var authRequest = await sutProvider.Sut.CreateAuthRequestAsync(createModel);
|
||||||
|
|
||||||
|
Assert.Equal(organizationUser1.OrganizationId, authRequest.OrganizationId);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IAuthRequestRepository>()
|
||||||
|
.Received(1)
|
||||||
|
.CreateAsync(Arg.Is<AuthRequest>(o => o.OrganizationId == organizationUser1.OrganizationId));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IAuthRequestRepository>()
|
||||||
|
.Received(1)
|
||||||
|
.CreateAsync(Arg.Any<AuthRequest>());
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IEventService>()
|
||||||
|
.Received(1)
|
||||||
|
.LogUserEventAsync(user.Id, EventType.User_RequestedDeviceApproval);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IMailService>()
|
||||||
|
.Received(0)
|
||||||
|
.SendDeviceApprovalRequestedNotificationEmailAsync(
|
||||||
|
Arg.Any<IEnumerable<string>>(),
|
||||||
|
Arg.Any<Guid>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>());
|
||||||
|
|
||||||
|
var expectedLogMessage = "There are no admin emails to send to.";
|
||||||
|
sutProvider.GetDependency<ILogger<AuthRequestService>>()
|
||||||
|
.Received(1)
|
||||||
|
.LogWarning(expectedLogMessage);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Story: When an <see cref="AuthRequest"> is approved we want to update it in the database so it cannot have
|
/// Story: When an <see cref="AuthRequest"> is approved we want to update it in the database so it cannot have
|
||||||
/// it's status changed again and we want to push a notification to let the user know of the approval.
|
/// it's status changed again and we want to push a notification to let the user know of the approval.
|
||||||
|
@ -0,0 +1,492 @@
|
|||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Stripe;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Billing.Services.Implementations.AutomaticTax;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class BusinessUseAutomaticTaxStrategyTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_ReturnsNull_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription();
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.Null(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_ReturnsNull_WhenSubscriptionDoesNotNeedUpdating(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "US",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.Null(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.NotNull(actual);
|
||||||
|
Assert.False(actual.AutomaticTax.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForAmericanCustomers(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = false
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "US",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.NotNull(actual);
|
||||||
|
Assert.True(actual.AutomaticTax.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = false
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "ES",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
},
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = new List<TaxId>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Country = "ES",
|
||||||
|
Type = "eu_vat",
|
||||||
|
Value = "ESZ8880999Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.NotNull(actual);
|
||||||
|
Assert.True(actual.AutomaticTax.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_ThrowsArgumentNullException_WhenTaxIdsIsNull(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "ES",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
},
|
||||||
|
TaxIds = null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>(() => sutProvider.Sut.GetUpdateOptions(subscription));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "ES",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
},
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = new List<TaxId>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.NotNull(actual);
|
||||||
|
Assert.False(actual.AutomaticTax.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void SetUpdateOptions_SetsNothing_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var options = new SubscriptionUpdateOptions();
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new()
|
||||||
|
{
|
||||||
|
Country = "US"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||||
|
|
||||||
|
Assert.Null(options.AutomaticTax);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void SetUpdateOptions_SetsNothing_WhenSubscriptionDoesNotNeedUpdating(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var options = new SubscriptionUpdateOptions();
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "US",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||||
|
|
||||||
|
Assert.Null(options.AutomaticTax);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void SetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var options = new SubscriptionUpdateOptions();
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||||
|
|
||||||
|
Assert.False(options.AutomaticTax.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForAmericanCustomers(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var options = new SubscriptionUpdateOptions();
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = false
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "US",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||||
|
|
||||||
|
Assert.True(options.AutomaticTax!.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var options = new SubscriptionUpdateOptions();
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = false
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "ES",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
},
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = new List<TaxId>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Country = "ES",
|
||||||
|
Type = "eu_vat",
|
||||||
|
Value = "ESZ8880999Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||||
|
|
||||||
|
Assert.True(options.AutomaticTax!.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void SetUpdateOptions_ThrowsArgumentNullException_WhenTaxIdsIsNull(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var options = new SubscriptionUpdateOptions();
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "ES",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
},
|
||||||
|
TaxIds = null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>(() => sutProvider.Sut.SetUpdateOptions(options, subscription));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds(
|
||||||
|
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var options = new SubscriptionUpdateOptions();
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "ES",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
},
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = new List<TaxId>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||||
|
|
||||||
|
Assert.False(options.AutomaticTax!.Enabled);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,217 @@
|
|||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Stripe;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Billing.Services.Implementations.AutomaticTax;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class PersonalUseAutomaticTaxStrategyTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_ReturnsNull_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled(
|
||||||
|
SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription();
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.Null(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_ReturnsNull_WhenSubscriptionDoesNotNeedUpdating(
|
||||||
|
SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "US",
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.Null(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void GetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid(
|
||||||
|
SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.NotNull(actual);
|
||||||
|
Assert.False(actual.AutomaticTax.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData("CA")]
|
||||||
|
[BitAutoData("ES")]
|
||||||
|
[BitAutoData("US")]
|
||||||
|
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForAllCountries(
|
||||||
|
string country, SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = false
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = country
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.NotNull(actual);
|
||||||
|
Assert.True(actual.AutomaticTax.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData("CA")]
|
||||||
|
[BitAutoData("ES")]
|
||||||
|
[BitAutoData("US")]
|
||||||
|
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds(
|
||||||
|
string country, SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = false
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = country,
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
},
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = new List<TaxId>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Country = "ES",
|
||||||
|
Type = "eu_vat",
|
||||||
|
Value = "ESZ8880999Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.NotNull(actual);
|
||||||
|
Assert.True(actual.AutomaticTax.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData("CA")]
|
||||||
|
[BitAutoData("ES")]
|
||||||
|
[BitAutoData("US")]
|
||||||
|
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds(
|
||||||
|
string country, SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||||
|
{
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax
|
||||||
|
{
|
||||||
|
Enabled = false
|
||||||
|
},
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = country
|
||||||
|
},
|
||||||
|
Tax = new CustomerTax
|
||||||
|
{
|
||||||
|
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||||
|
},
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = new List<TaxId>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||||
|
|
||||||
|
Assert.NotNull(actual);
|
||||||
|
Assert.True(actual.AutomaticTax.Enabled);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,105 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||||
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
|
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Billing.Services.Implementations;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class AutomaticTaxFactoryTests
|
||||||
|
{
|
||||||
|
[BitAutoData]
|
||||||
|
[Theory]
|
||||||
|
public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenSubscriberIsUser(SutProvider<AutomaticTaxFactory> sut)
|
||||||
|
{
|
||||||
|
var parameters = new AutomaticTaxFactoryParameters(new User(), []);
|
||||||
|
|
||||||
|
var actual = await sut.Sut.CreateAsync(parameters);
|
||||||
|
|
||||||
|
Assert.IsType<PersonalUseAutomaticTaxStrategy>(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[BitAutoData]
|
||||||
|
[Theory]
|
||||||
|
public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenSubscriberIsOrganizationWithFamiliesAnnuallyPrice(
|
||||||
|
SutProvider<AutomaticTaxFactory> sut)
|
||||||
|
{
|
||||||
|
var familiesPlan = new FamiliesPlan();
|
||||||
|
var parameters = new AutomaticTaxFactoryParameters(new Organization(), [familiesPlan.PasswordManager.StripePlanId]);
|
||||||
|
|
||||||
|
sut.GetDependency<IPricingClient>()
|
||||||
|
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||||
|
.Returns(new FamiliesPlan());
|
||||||
|
|
||||||
|
sut.GetDependency<IPricingClient>()
|
||||||
|
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually2019))
|
||||||
|
.Returns(new Families2019Plan());
|
||||||
|
|
||||||
|
var actual = await sut.Sut.CreateAsync(parameters);
|
||||||
|
|
||||||
|
Assert.IsType<PersonalUseAutomaticTaxStrategy>(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task CreateAsync_ReturnsBusinessUseStrategy_WhenSubscriberIsOrganizationWithBusinessUsePrice(
|
||||||
|
EnterpriseAnnually plan,
|
||||||
|
SutProvider<AutomaticTaxFactory> sut)
|
||||||
|
{
|
||||||
|
var parameters = new AutomaticTaxFactoryParameters(new Organization(), [plan.PasswordManager.StripePlanId]);
|
||||||
|
|
||||||
|
sut.GetDependency<IPricingClient>()
|
||||||
|
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||||
|
.Returns(new FamiliesPlan());
|
||||||
|
|
||||||
|
sut.GetDependency<IPricingClient>()
|
||||||
|
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually2019))
|
||||||
|
.Returns(new Families2019Plan());
|
||||||
|
|
||||||
|
var actual = await sut.Sut.CreateAsync(parameters);
|
||||||
|
|
||||||
|
Assert.IsType<BusinessUseAutomaticTaxStrategy>(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenPlanIsMeantForPersonalUse(SutProvider<AutomaticTaxFactory> sut)
|
||||||
|
{
|
||||||
|
var parameters = new AutomaticTaxFactoryParameters(PlanType.FamiliesAnnually);
|
||||||
|
sut.GetDependency<IPricingClient>()
|
||||||
|
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == parameters.PlanType.Value))
|
||||||
|
.Returns(new FamiliesPlan());
|
||||||
|
|
||||||
|
var actual = await sut.Sut.CreateAsync(parameters);
|
||||||
|
|
||||||
|
Assert.IsType<PersonalUseAutomaticTaxStrategy>(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task CreateAsync_ReturnsBusinessUseStrategy_WhenPlanIsMeantForBusinessUse(SutProvider<AutomaticTaxFactory> sut)
|
||||||
|
{
|
||||||
|
var parameters = new AutomaticTaxFactoryParameters(PlanType.EnterpriseAnnually);
|
||||||
|
sut.GetDependency<IPricingClient>()
|
||||||
|
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == parameters.PlanType.Value))
|
||||||
|
.Returns(new EnterprisePlan(true));
|
||||||
|
|
||||||
|
var actual = await sut.Sut.CreateAsync(parameters);
|
||||||
|
|
||||||
|
Assert.IsType<BusinessUseAutomaticTaxStrategy>(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EnterpriseAnnually : EnterprisePlan
|
||||||
|
{
|
||||||
|
public EnterpriseAnnually() : base(true)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3,10 +3,13 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
|||||||
using Bit.Core.Billing.Caches;
|
using Bit.Core.Billing.Caches;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
using Bit.Core.Billing.Services.Implementations;
|
using Bit.Core.Billing.Services.Implementations;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Test.Billing.Stubs;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using Braintree;
|
using Braintree;
|
||||||
@ -1167,7 +1170,9 @@ public class SubscriberServiceTests
|
|||||||
{
|
{
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)
|
stripeAdapter.CustomerGetAsync(
|
||||||
|
provider.GatewayCustomerId,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
|
||||||
.Returns(new Customer
|
.Returns(new Customer
|
||||||
{
|
{
|
||||||
Id = provider.GatewayCustomerId,
|
Id = provider.GatewayCustomerId,
|
||||||
@ -1213,7 +1218,10 @@ public class SubscriberServiceTests
|
|||||||
{
|
{
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)
|
stripeAdapter.CustomerGetAsync(
|
||||||
|
provider.GatewayCustomerId,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))
|
||||||
|
)
|
||||||
.Returns(new Customer
|
.Returns(new Customer
|
||||||
{
|
{
|
||||||
Id = provider.GatewayCustomerId,
|
Id = provider.GatewayCustomerId,
|
||||||
@ -1321,7 +1329,9 @@ public class SubscriberServiceTests
|
|||||||
{
|
{
|
||||||
const string braintreeCustomerId = "braintree_customer_id";
|
const string braintreeCustomerId = "braintree_customer_id";
|
||||||
|
|
||||||
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(provider.GatewayCustomerId)
|
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(
|
||||||
|
provider.GatewayCustomerId,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
|
||||||
.Returns(new Customer
|
.Returns(new Customer
|
||||||
{
|
{
|
||||||
Id = provider.GatewayCustomerId,
|
Id = provider.GatewayCustomerId,
|
||||||
@ -1373,7 +1383,9 @@ public class SubscriberServiceTests
|
|||||||
{
|
{
|
||||||
const string braintreeCustomerId = "braintree_customer_id";
|
const string braintreeCustomerId = "braintree_customer_id";
|
||||||
|
|
||||||
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(provider.GatewayCustomerId)
|
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(
|
||||||
|
provider.GatewayCustomerId,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
|
||||||
.Returns(new Customer
|
.Returns(new Customer
|
||||||
{
|
{
|
||||||
Id = provider.GatewayCustomerId,
|
Id = provider.GatewayCustomerId,
|
||||||
@ -1482,7 +1494,9 @@ public class SubscriberServiceTests
|
|||||||
{
|
{
|
||||||
const string braintreeCustomerId = "braintree_customer_id";
|
const string braintreeCustomerId = "braintree_customer_id";
|
||||||
|
|
||||||
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(provider.GatewayCustomerId)
|
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(
|
||||||
|
provider.GatewayCustomerId,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
|
||||||
.Returns(new Customer
|
.Returns(new Customer
|
||||||
{
|
{
|
||||||
Id = provider.GatewayCustomerId
|
Id = provider.GatewayCustomerId
|
||||||
@ -1561,6 +1575,37 @@ public class SubscriberServiceTests
|
|||||||
"Example Town",
|
"Example Town",
|
||||||
"NY");
|
"NY");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>()
|
||||||
|
.CustomerUpdateAsync(
|
||||||
|
Arg.Is<string>(p => p == provider.GatewayCustomerId),
|
||||||
|
Arg.Is<CustomerUpdateOptions>(options =>
|
||||||
|
options.Address.Country == "US" &&
|
||||||
|
options.Address.PostalCode == "12345" &&
|
||||||
|
options.Address.Line1 == "123 Example St." &&
|
||||||
|
options.Address.Line2 == null &&
|
||||||
|
options.Address.City == "Example Town" &&
|
||||||
|
options.Address.State == "NY"))
|
||||||
|
.Returns(new Customer
|
||||||
|
{
|
||||||
|
Id = provider.GatewayCustomerId,
|
||||||
|
Address = new Address
|
||||||
|
{
|
||||||
|
Country = "US",
|
||||||
|
PostalCode = "12345",
|
||||||
|
Line1 = "123 Example St.",
|
||||||
|
Line2 = null,
|
||||||
|
City = "Example Town",
|
||||||
|
State = "NY"
|
||||||
|
},
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] }
|
||||||
|
});
|
||||||
|
|
||||||
|
var subscription = new Subscription { Items = new StripeList<SubscriptionItem>() };
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(Arg.Any<string>())
|
||||||
|
.Returns(subscription);
|
||||||
|
sutProvider.GetDependency<IAutomaticTaxFactory>().CreateAsync(Arg.Any<AutomaticTaxFactoryParameters>())
|
||||||
|
.Returns(new FakeAutomaticTaxStrategy(true));
|
||||||
|
|
||||||
await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation);
|
await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation);
|
||||||
|
|
||||||
await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
|
await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
|
||||||
|
35
test/Core.Test/Billing/Stubs/FakeAutomaticTaxStrategy.cs
Normal file
35
test/Core.Test/Billing/Stubs/FakeAutomaticTaxStrategy.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Billing.Stubs;
|
||||||
|
|
||||||
|
/// <param name="isAutomaticTaxEnabled">
|
||||||
|
/// Whether the subscription options will have automatic tax enabled or not.
|
||||||
|
/// </param>
|
||||||
|
public class FakeAutomaticTaxStrategy(
|
||||||
|
bool isAutomaticTaxEnabled) : IAutomaticTaxStrategy
|
||||||
|
{
|
||||||
|
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
|
||||||
|
{
|
||||||
|
return new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
|
||||||
|
{
|
||||||
|
options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
|
||||||
|
{
|
||||||
|
options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
|
||||||
|
{
|
||||||
|
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled };
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,25 @@
|
|||||||
|
-- Recreate the NotificationStatusView to include the Notification.TaskId column
|
||||||
|
CREATE OR ALTER VIEW [dbo].[NotificationStatusDetailsView]
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
N.[Id],
|
||||||
|
N.[Priority],
|
||||||
|
N.[Global],
|
||||||
|
N.[ClientType],
|
||||||
|
N.[UserId],
|
||||||
|
N.[OrganizationId],
|
||||||
|
N.[Title],
|
||||||
|
N.[Body],
|
||||||
|
N.[CreationDate],
|
||||||
|
N.[RevisionDate],
|
||||||
|
N.[TaskId],
|
||||||
|
NS.[UserId] AS [NotificationStatusUserId],
|
||||||
|
NS.[ReadDate],
|
||||||
|
NS.[DeletedDate]
|
||||||
|
FROM
|
||||||
|
[dbo].[Notification] AS N
|
||||||
|
LEFT JOIN
|
||||||
|
[dbo].[NotificationStatus] as NS
|
||||||
|
ON
|
||||||
|
N.[Id] = NS.[NotificationId]
|
||||||
|
GO
|
Loading…
x
Reference in New Issue
Block a user