mirror of
https://github.com/bitwarden/server.git
synced 2025-05-07 04:32:20 -05:00
Merge branch 'main' into feat/pm-14496-non-root-self-hosted-images
This commit is contained in:
commit
4bbf003104
13
.github/workflows/code-references.yml
vendored
13
.github/workflows/code-references.yml
vendored
@ -1,7 +1,10 @@
|
|||||||
name: Collect code references
|
name: Collect code references
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
push:
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-ld-secret:
|
check-ld-secret:
|
||||||
@ -37,12 +40,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Collect
|
- name: Collect
|
||||||
id: collect
|
id: collect
|
||||||
uses: launchdarkly/find-code-references-in-pull-request@30f4c4ab2949bbf258b797ced2fbf6dea34df9ce # v2.1.0
|
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
|
||||||
with:
|
with:
|
||||||
project-key: default
|
accessToken: ${{ secrets.LD_ACCESS_TOKEN }}
|
||||||
environment-key: dev
|
projKey: default
|
||||||
access-token: ${{ secrets.LD_ACCESS_TOKEN }}
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Add label
|
- name: Add label
|
||||||
if: steps.collect.outputs.any-changed == 'true'
|
if: steps.collect.outputs.any-changed == 'true'
|
||||||
|
@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
|||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -82,7 +83,7 @@ public class ProviderService : IProviderService
|
|||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
|
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||||
{
|
{
|
||||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||||
if (owner == null)
|
if (owner == null)
|
||||||
@ -111,7 +112,20 @@ public class ProviderService : IProviderService
|
|||||||
{
|
{
|
||||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||||
}
|
}
|
||||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
|
|
||||||
|
var requireProviderPaymentMethodDuringSetup =
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||||
|
|
||||||
|
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource is not
|
||||||
|
{
|
||||||
|
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||||
|
Token: not null and not ""
|
||||||
|
})
|
||||||
|
{
|
||||||
|
throw new BadRequestException("A payment method is required to set up your provider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||||
provider.GatewayCustomerId = customer.Id;
|
provider.GatewayCustomerId = customer.Id;
|
||||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||||
provider.GatewaySubscriptionId = subscription.Id;
|
provider.GatewaySubscriptionId = subscription.Id;
|
||||||
|
@ -6,9 +6,11 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
|||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing;
|
using Bit.Core.Billing;
|
||||||
|
using Bit.Core.Billing.Caches;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
@ -21,14 +23,20 @@ using Bit.Core.Models.Business;
|
|||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
|
using Braintree;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
|
||||||
|
using static Bit.Core.Billing.Utilities;
|
||||||
|
using Customer = Stripe.Customer;
|
||||||
|
using Subscription = Stripe.Subscription;
|
||||||
|
|
||||||
namespace Bit.Commercial.Core.Billing;
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
public class ProviderBillingService(
|
public class ProviderBillingService(
|
||||||
|
IBraintreeGateway braintreeGateway,
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
@ -39,6 +47,7 @@ public class ProviderBillingService(
|
|||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
|
ISetupIntentCache setupIntentCache,
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
ITaxService taxService,
|
ITaxService taxService,
|
||||||
@ -463,7 +472,8 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
public async Task<Customer> SetupCustomer(
|
public async Task<Customer> SetupCustomer(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
TaxInfo taxInfo)
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||||
{
|
{
|
||||||
if (taxInfo is not
|
if (taxInfo is not
|
||||||
{
|
{
|
||||||
@ -532,13 +542,97 @@ public class ProviderBillingService(
|
|||||||
options.Coupon = provider.DiscountId;
|
options.Coupon = provider.DiscountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var requireProviderPaymentMethodDuringSetup =
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||||
|
|
||||||
|
var braintreeCustomerId = "";
|
||||||
|
|
||||||
|
if (requireProviderPaymentMethodDuringSetup)
|
||||||
|
{
|
||||||
|
if (tokenizedPaymentSource is not
|
||||||
|
{
|
||||||
|
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||||
|
Token: not null and not ""
|
||||||
|
})
|
||||||
|
{
|
||||||
|
logger.LogError("Cannot create customer for provider ({ProviderID}) without a payment method", provider.Id);
|
||||||
|
throw new BillingException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var (type, token) = tokenizedPaymentSource;
|
||||||
|
|
||||||
|
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case PaymentMethodType.BankAccount:
|
||||||
|
{
|
||||||
|
var setupIntent =
|
||||||
|
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (setupIntent == null)
|
||||||
|
{
|
||||||
|
logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id);
|
||||||
|
throw new BillingException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupIntentCache.Set(provider.Id, setupIntent.Id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PaymentMethodType.Card:
|
||||||
|
{
|
||||||
|
options.PaymentMethod = token;
|
||||||
|
options.InvoiceSettings.DefaultPaymentMethod = token;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PaymentMethodType.PayPal:
|
||||||
|
{
|
||||||
|
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token);
|
||||||
|
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await stripeAdapter.CustomerCreateAsync(options);
|
return await stripeAdapter.CustomerCreateAsync(options);
|
||||||
}
|
}
|
||||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.TaxIdInvalid)
|
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
|
||||||
|
StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
await Revert();
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await Revert();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Revert()
|
||||||
|
{
|
||||||
|
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null)
|
||||||
|
{
|
||||||
|
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||||
|
switch (tokenizedPaymentSource.Type)
|
||||||
|
{
|
||||||
|
case PaymentMethodType.BankAccount:
|
||||||
|
{
|
||||||
|
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||||
|
await stripeAdapter.SetupIntentCancel(setupIntentId,
|
||||||
|
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
|
||||||
|
await setupIntentCache.Remove(provider.Id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||||
|
{
|
||||||
|
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -580,18 +674,38 @@ public class ProviderBillingService(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var requireProviderPaymentMethodDuringSetup =
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||||
|
|
||||||
|
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||||
|
|
||||||
|
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
|
||||||
|
? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
|
||||||
|
{
|
||||||
|
Expand = ["payment_method"]
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var usePaymentMethod =
|
||||||
|
requireProviderPaymentMethodDuringSetup &&
|
||||||
|
(!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||||
|
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) ||
|
||||||
|
setupIntent.IsUnverifiedBankAccount());
|
||||||
|
|
||||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||||
{
|
{
|
||||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
CollectionMethod = usePaymentMethod ?
|
||||||
|
StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice,
|
||||||
Customer = customer.Id,
|
Customer = customer.Id,
|
||||||
DaysUntilDue = 30,
|
DaysUntilDue = usePaymentMethod ? null : 30,
|
||||||
Items = subscriptionItemOptionsList,
|
Items = subscriptionItemOptionsList,
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
{ "providerId", provider.Id.ToString() }
|
{ "providerId", provider.Id.ToString() }
|
||||||
},
|
},
|
||||||
OffSession = true,
|
OffSession = true,
|
||||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
|
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||||
|
TrialPeriodDays = usePaymentMethod ? 14 : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||||
@ -607,7 +721,10 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||||
|
|
||||||
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
|
if (subscription is
|
||||||
|
{
|
||||||
|
Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing
|
||||||
|
})
|
||||||
{
|
{
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using Bit.Commercial.Core.AdminConsole.Services;
|
using Bit.Commercial.Core.AdminConsole.Services;
|
||||||
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
||||||
|
using Bit.Core;
|
||||||
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.Enums.Provider;
|
||||||
@ -7,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Provider;
|
|||||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -38,7 +40,7 @@ public class ProviderServiceTests
|
|||||||
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
|
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default));
|
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null));
|
||||||
Assert.Contains("Invalid owner.", exception.Message);
|
Assert.Contains("Invalid owner.", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,12 +52,85 @@ public class ProviderServiceTests
|
|||||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||||
|
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default));
|
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null));
|
||||||
Assert.Contains("Invalid token.", exception.Message);
|
Assert.Contains("Invalid token.", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException(
|
||||||
|
User user,
|
||||||
|
Provider provider,
|
||||||
|
string key,
|
||||||
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource,
|
||||||
|
[ProviderUser] ProviderUser providerUser,
|
||||||
|
SutProvider<ProviderService> sutProvider)
|
||||||
|
{
|
||||||
|
providerUser.ProviderId = provider.Id;
|
||||||
|
providerUser.UserId = user.Id;
|
||||||
|
var userService = sutProvider.GetDependency<IUserService>();
|
||||||
|
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||||
|
|
||||||
|
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||||
|
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||||
|
|
||||||
|
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||||
|
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||||
|
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||||
|
.Returns(protector);
|
||||||
|
|
||||||
|
sutProvider.Create();
|
||||||
|
|
||||||
|
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = null;
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
|
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||||
|
|
||||||
|
Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException(
|
||||||
|
User user,
|
||||||
|
Provider provider,
|
||||||
|
string key,
|
||||||
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource,
|
||||||
|
[ProviderUser] ProviderUser providerUser,
|
||||||
|
SutProvider<ProviderService> sutProvider)
|
||||||
|
{
|
||||||
|
providerUser.ProviderId = provider.Id;
|
||||||
|
providerUser.UserId = user.Id;
|
||||||
|
var userService = sutProvider.GetDependency<IUserService>();
|
||||||
|
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||||
|
|
||||||
|
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||||
|
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||||
|
|
||||||
|
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||||
|
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||||
|
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||||
|
.Returns(protector);
|
||||||
|
|
||||||
|
sutProvider.Create();
|
||||||
|
|
||||||
|
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
|
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||||
|
|
||||||
|
Assert.Equal("A payment method is required to set up your provider.", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource,
|
||||||
[ProviderUser] ProviderUser providerUser,
|
[ProviderUser] ProviderUser providerUser,
|
||||||
SutProvider<ProviderService> sutProvider)
|
SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
@ -75,7 +150,7 @@ public class ProviderServiceTests
|
|||||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||||
|
|
||||||
var customer = new Customer { Id = "customer_id" };
|
var customer = new Customer { Id = "customer_id" };
|
||||||
providerBillingService.SetupCustomer(provider, taxInfo).Returns(customer);
|
providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer);
|
||||||
|
|
||||||
var subscription = new Subscription { Id = "subscription_id" };
|
var subscription = new Subscription { Id = "subscription_id" };
|
||||||
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
||||||
@ -84,7 +159,7 @@ public class ProviderServiceTests
|
|||||||
|
|
||||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||||
|
|
||||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo);
|
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
|
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
|
||||||
p =>
|
p =>
|
||||||
|
@ -2,14 +2,17 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using Bit.Commercial.Core.Billing;
|
using Bit.Commercial.Core.Billing;
|
||||||
using Bit.Commercial.Core.Billing.Models;
|
using Bit.Commercial.Core.Billing.Models;
|
||||||
|
using Bit.Core;
|
||||||
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.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.Caches;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Pricing;
|
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;
|
||||||
@ -24,11 +27,17 @@ using Bit.Core.Settings;
|
|||||||
using Bit.Core.Utilities;
|
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 Braintree;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using static Bit.Core.Test.Billing.Utilities;
|
using static Bit.Core.Test.Billing.Utilities;
|
||||||
|
using Address = Stripe.Address;
|
||||||
|
using Customer = Stripe.Customer;
|
||||||
|
using PaymentMethod = Stripe.PaymentMethod;
|
||||||
|
using Subscription = Stripe.Subscription;
|
||||||
|
|
||||||
namespace Bit.Commercial.Core.Test.Billing;
|
namespace Bit.Commercial.Core.Test.Billing;
|
||||||
|
|
||||||
@ -833,7 +842,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task SetupCustomer_Success(
|
public async Task SetupCustomer_NoPaymentMethod_Success(
|
||||||
SutProvider<ProviderBillingService> sutProvider,
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
Provider provider,
|
Provider provider,
|
||||||
TaxInfo taxInfo)
|
TaxInfo taxInfo)
|
||||||
@ -877,6 +886,301 @@ public class ProviderBillingServiceTests
|
|||||||
Assert.Equivalent(expected, actual);
|
Assert.Equivalent(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
|
||||||
|
|
||||||
|
await ThrowsBillingExceptionAsync(() =>
|
||||||
|
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithBankAccount_Error_Reverts(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
|
||||||
|
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
|
||||||
|
new SetupIntent { Id = "setup_intent_id" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Throws<StripeException>();
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns("setup_intent_id");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<StripeException>(() =>
|
||||||
|
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
|
||||||
|
|
||||||
|
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
|
||||||
|
options.CancellationReason == "abandoned"));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Remove(provider.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithPayPal_Error_Reverts(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
|
||||||
|
.Returns("braintree_customer_id");
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Throws<StripeException>();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<StripeException>(() =>
|
||||||
|
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithBankAccount_Success(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var expected = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
|
||||||
|
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
|
||||||
|
new SetupIntent { Id = "setup_intent_id" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithPayPal_Success(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var expected = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
|
||||||
|
.Returns("braintree_customer_id");
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupCustomer_WithCard_Success(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider,
|
||||||
|
TaxInfo taxInfo)
|
||||||
|
{
|
||||||
|
provider.Name = "MSP";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITaxService>()
|
||||||
|
.GetStripeTaxCode(Arg.Is<string>(
|
||||||
|
p => p == taxInfo.BillingAddressCountry),
|
||||||
|
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(taxInfo.TaxIdType);
|
||||||
|
|
||||||
|
taxInfo.BillingAddressCountry = "AD";
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
|
var expected = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||||
|
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||||
|
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||||
|
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||||
|
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||||
|
o.Address.City == taxInfo.BillingAddressCity &&
|
||||||
|
o.Address.State == taxInfo.BillingAddressState &&
|
||||||
|
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||||
|
o.Email == provider.BillingEmail &&
|
||||||
|
o.PaymentMethod == tokenizedPaymentSource.Token &&
|
||||||
|
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||||
|
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||||
|
o.Metadata["region"] == "" &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||||
|
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||||
|
.Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
|
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
|
||||||
SutProvider<ProviderBillingService> sutProvider,
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
@ -1044,7 +1348,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task SetupSubscription_Succeeds(
|
public async Task SetupSubscription_SendInvoice_Succeeds(
|
||||||
SutProvider<ProviderBillingService> sutProvider,
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
Provider provider)
|
Provider provider)
|
||||||
{
|
{
|
||||||
@ -1127,6 +1431,303 @@ public class ProviderBillingServiceTests
|
|||||||
Assert.Equivalent(expected, actual);
|
Assert.Equivalent(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupSubscription_ChargeAutomatically_HasCard_Succeeds(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider)
|
||||||
|
{
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettings
|
||||||
|
{
|
||||||
|
DefaultPaymentMethodId = "pm_123"
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.TeamsMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var plan in providerPlans)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
|
.Returns(StaticStore.GetPlan(plan.PlanType));
|
||||||
|
}
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
|
.Returns(providerPlans);
|
||||||
|
|
||||||
|
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<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||||
|
sub =>
|
||||||
|
sub.AutomaticTax.Enabled == true &&
|
||||||
|
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
|
||||||
|
sub.Customer == "customer_id" &&
|
||||||
|
sub.DaysUntilDue == null &&
|
||||||
|
sub.Items.Count == 2 &&
|
||||||
|
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||||
|
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||||
|
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||||
|
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||||
|
sub.OffSession == true &&
|
||||||
|
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||||
|
sub.TrialPeriodDays == 14)).Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupSubscription_ChargeAutomatically_HasBankAccount_Succeeds(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider)
|
||||||
|
{
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||||
|
Metadata = new Dictionary<string, string>(),
|
||||||
|
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>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.TeamsMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var plan in providerPlans)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
|
.Returns(StaticStore.GetPlan(plan.PlanType));
|
||||||
|
}
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
|
.Returns(providerPlans);
|
||||||
|
|
||||||
|
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<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
const string setupIntentId = "seti_123";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntentId);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
|
||||||
|
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
|
||||||
|
{
|
||||||
|
Id = setupIntentId,
|
||||||
|
Status = "requires_action",
|
||||||
|
NextAction = new SetupIntentNextAction
|
||||||
|
{
|
||||||
|
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
|
||||||
|
},
|
||||||
|
PaymentMethod = new PaymentMethod
|
||||||
|
{
|
||||||
|
UsBankAccount = new PaymentMethodUsBankAccount()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||||
|
sub =>
|
||||||
|
sub.AutomaticTax.Enabled == true &&
|
||||||
|
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
|
||||||
|
sub.Customer == "customer_id" &&
|
||||||
|
sub.DaysUntilDue == null &&
|
||||||
|
sub.Items.Count == 2 &&
|
||||||
|
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||||
|
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||||
|
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||||
|
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||||
|
sub.OffSession == true &&
|
||||||
|
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||||
|
sub.TrialPeriodDays == 14)).Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task SetupSubscription_ChargeAutomatically_HasPayPal_Succeeds(
|
||||||
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
|
Provider provider)
|
||||||
|
{
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["btCustomerId"] = "braintree_customer_id"
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.TeamsMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.EnterpriseMonthly,
|
||||||
|
SeatMinimum = 100,
|
||||||
|
PurchasedSeats = 0,
|
||||||
|
AllocatedSeats = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var plan in providerPlans)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
|
.Returns(StaticStore.GetPlan(plan.PlanType));
|
||||||
|
}
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
|
.Returns(providerPlans);
|
||||||
|
|
||||||
|
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<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||||
|
sub =>
|
||||||
|
sub.AutomaticTax.Enabled == true &&
|
||||||
|
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
|
||||||
|
sub.Customer == "customer_id" &&
|
||||||
|
sub.DaysUntilDue == null &&
|
||||||
|
sub.Items.Count == 2 &&
|
||||||
|
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||||
|
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||||
|
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||||
|
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||||
|
sub.OffSession == true &&
|
||||||
|
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||||
|
sub.TrialPeriodDays == 14)).Returns(expected);
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||||
|
|
||||||
|
Assert.Equivalent(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region UpdateSeatMinimums
|
#region UpdateSeatMinimums
|
||||||
|
@ -9,7 +9,7 @@ namespace Bit.Api.AdminConsole.Authorization;
|
|||||||
public static class HttpContextExtensions
|
public static class HttpContextExtensions
|
||||||
{
|
{
|
||||||
public const string NoOrgIdError =
|
public const string NoOrgIdError =
|
||||||
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute.";
|
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' or 'organizationId' either through the [Controller] attribute or through a '[Http*]' attribute.";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
|
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
|
||||||
@ -61,19 +61,27 @@ public static class HttpContextExtensions
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid.
|
/// Parses the {orgId} or {organizationId} route parameter into a Guid, or throws if neither are present or are not valid guids.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="httpContext"></param>
|
/// <param name="httpContext"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
/// <exception cref="InvalidOperationException"></exception>
|
/// <exception cref="InvalidOperationException"></exception>
|
||||||
public static Guid GetOrganizationId(this HttpContext httpContext)
|
public static Guid GetOrganizationId(this HttpContext httpContext)
|
||||||
{
|
{
|
||||||
httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam);
|
var routeValues = httpContext.GetRouteData().Values;
|
||||||
if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(NoOrgIdError);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
routeValues.TryGetValue("orgId", out var orgIdParam);
|
||||||
|
if (orgIdParam != null && Guid.TryParse(orgIdParam.ToString(), out var orgId))
|
||||||
|
{
|
||||||
return orgId;
|
return orgId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
routeValues.TryGetValue("organizationId", out var organizationIdParam);
|
||||||
|
if (organizationIdParam != null && Guid.TryParse(organizationIdParam.ToString(), out var organizationId))
|
||||||
|
{
|
||||||
|
return organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(NoOrgIdError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -63,6 +63,7 @@ public class OrganizationUsersController : Controller
|
|||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
||||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||||
|
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
||||||
|
|
||||||
public OrganizationUsersController(
|
public OrganizationUsersController(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -89,7 +90,8 @@ public class OrganizationUsersController : Controller
|
|||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
|
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
|
||||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
|
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||||
|
IInitPendingOrganizationCommand initPendingOrganizationCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -116,6 +118,7 @@ public class OrganizationUsersController : Controller
|
|||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
||||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||||
|
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@ -313,7 +316,7 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _organizationService.InitPendingOrganization(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token);
|
await _initPendingOrganizationCommand.InitPendingOrganizationAsync(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token);
|
||||||
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
|
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
|
||||||
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
|
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
|
||||||
}
|
}
|
||||||
|
@ -84,8 +84,7 @@ public class ProvidersController : Controller
|
|||||||
|
|
||||||
var userId = _userService.GetProperUserId(User).Value;
|
var userId = _userService.GetProperUserId(User).Value;
|
||||||
|
|
||||||
var taxInfo = model.TaxInfo != null
|
var taxInfo = new TaxInfo
|
||||||
? new TaxInfo
|
|
||||||
{
|
{
|
||||||
BillingAddressCountry = model.TaxInfo.Country,
|
BillingAddressCountry = model.TaxInfo.Country,
|
||||||
BillingAddressPostalCode = model.TaxInfo.PostalCode,
|
BillingAddressPostalCode = model.TaxInfo.PostalCode,
|
||||||
@ -94,12 +93,13 @@ public class ProvidersController : Controller
|
|||||||
BillingAddressLine2 = model.TaxInfo.Line2,
|
BillingAddressLine2 = model.TaxInfo.Line2,
|
||||||
BillingAddressCity = model.TaxInfo.City,
|
BillingAddressCity = model.TaxInfo.City,
|
||||||
BillingAddressState = model.TaxInfo.State
|
BillingAddressState = model.TaxInfo.State
|
||||||
}
|
};
|
||||||
: null;
|
|
||||||
|
var tokenizedPaymentSource = model.PaymentSource?.ToDomain();
|
||||||
|
|
||||||
var response =
|
var response =
|
||||||
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key,
|
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key,
|
||||||
taxInfo);
|
taxInfo, tokenizedPaymentSource);
|
||||||
|
|
||||||
return new ProviderResponseModel(response);
|
return new ProviderResponseModel(response);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Bit.Api.Billing.Models.Requests;
|
||||||
using Bit.Api.Models.Request;
|
using Bit.Api.Models.Request;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
@ -23,7 +24,9 @@ public class ProviderSetupRequestModel
|
|||||||
public string Token { get; set; }
|
public string Token { get; set; }
|
||||||
[Required]
|
[Required]
|
||||||
public string Key { get; set; }
|
public string Key { get; set; }
|
||||||
|
[Required]
|
||||||
public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; }
|
public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; }
|
||||||
|
public TokenizedPaymentSourceRequestBody PaymentSource { get; set; }
|
||||||
|
|
||||||
public virtual Provider ToProvider(Provider provider)
|
public virtual Provider ToProvider(Provider provider)
|
||||||
{
|
{
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.Billing.Models.Requests;
|
using Bit.Api.Billing.Models.Requests;
|
||||||
using Bit.Api.Billing.Models.Responses;
|
using Bit.Api.Billing.Models.Responses;
|
||||||
|
using Bit.Api.Billing.Queries.Organizations;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Sales;
|
using Bit.Core.Billing.Models.Sales;
|
||||||
@ -24,6 +25,7 @@ public class OrganizationBillingController(
|
|||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IOrganizationBillingService organizationBillingService,
|
IOrganizationBillingService organizationBillingService,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
|
IOrganizationWarningsQuery organizationWarningsQuery,
|
||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
@ -335,4 +337,28 @@ public class OrganizationBillingController(
|
|||||||
|
|
||||||
return TypedResults.Ok(providerId);
|
return TypedResults.Ok(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("warnings")]
|
||||||
|
public async Task<IResult> GetWarningsAsync([FromRoute] Guid organizationId)
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* We'll keep these available at the User level, because we're hiding any pertinent information and
|
||||||
|
* we want to throw as few errors as possible since these are not core features.
|
||||||
|
*/
|
||||||
|
if (!await currentContext.OrganizationUser(organizationId))
|
||||||
|
{
|
||||||
|
return Error.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
return Error.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await organizationWarningsQuery.Run(organization);
|
||||||
|
|
||||||
|
return TypedResults.Ok(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Api.Models.Request.Organizations;
|
using Bit.Api.Models.Request.Organizations;
|
||||||
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Models.Response.Organizations;
|
using Bit.Api.Models.Response.Organizations;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||||
@ -8,6 +9,7 @@ using Bit.Core.Entities;
|
|||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Api.Request.OrganizationSponsorships;
|
using Bit.Core.Models.Api.Request.OrganizationSponsorships;
|
||||||
using Bit.Core.Models.Api.Response.OrganizationSponsorships;
|
using Bit.Core.Models.Api.Response.OrganizationSponsorships;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationSponsorships;
|
||||||
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;
|
||||||
@ -105,8 +107,11 @@ public class OrganizationSponsorshipsController : Controller
|
|||||||
model.FriendlyName,
|
model.FriendlyName,
|
||||||
model.IsAdminInitiated.GetValueOrDefault(),
|
model.IsAdminInitiated.GetValueOrDefault(),
|
||||||
model.Notes);
|
model.Notes);
|
||||||
|
if (sponsorship.OfferedToEmail != null)
|
||||||
|
{
|
||||||
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
|
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
[HttpPost("{sponsoringOrgId}/families-for-enterprise/resend")]
|
[HttpPost("{sponsoringOrgId}/families-for-enterprise/resend")]
|
||||||
@ -246,5 +251,27 @@ public class OrganizationSponsorshipsController : Controller
|
|||||||
return new OrganizationSponsorshipSyncStatusResponseModel(lastSyncDate);
|
return new OrganizationSponsorshipSyncStatusResponseModel(lastSyncDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize("Application")]
|
||||||
|
[HttpGet("{sponsoringOrgId}/sponsored")]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public async Task<ListResponseModel<OrganizationSponsorshipInvitesResponseModel>> GetSponsoredOrganizations(Guid sponsoringOrgId)
|
||||||
|
{
|
||||||
|
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
|
||||||
|
if (sponsoringOrg == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
var organization = _currentContext.Organizations.First(x => x.Id == sponsoringOrg.Id);
|
||||||
|
if (!await _currentContext.OrganizationOwner(sponsoringOrg.Id) && !await _currentContext.OrganizationAdmin(sponsoringOrg.Id) && !organization.Permissions.ManageUsers)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId);
|
||||||
|
return new ListResponseModel<OrganizationSponsorshipInvitesResponseModel>(sponsorships.Select(s =>
|
||||||
|
new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s))));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
private Task<User> CurrentUser => _userService.GetUserByIdAsync(_currentContext.UserId.Value);
|
private Task<User> CurrentUser => _userService.GetUserByIdAsync(_currentContext.UserId.Value);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace Bit.Api.Billing.Models.Responses.Organizations;
|
||||||
|
|
||||||
|
public record OrganizationWarningsResponse
|
||||||
|
{
|
||||||
|
public FreeTrialWarning? FreeTrial { get; set; }
|
||||||
|
public InactiveSubscriptionWarning? InactiveSubscription { get; set; }
|
||||||
|
public ResellerRenewalWarning? ResellerRenewal { get; set; }
|
||||||
|
|
||||||
|
public record FreeTrialWarning
|
||||||
|
{
|
||||||
|
public int RemainingTrialDays { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InactiveSubscriptionWarning
|
||||||
|
{
|
||||||
|
public required string Resolution { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ResellerRenewalWarning
|
||||||
|
{
|
||||||
|
public required string Type { get; set; }
|
||||||
|
public UpcomingRenewal? Upcoming { get; set; }
|
||||||
|
public IssuedRenewal? Issued { get; set; }
|
||||||
|
public PastDueRenewal? PastDue { get; set; }
|
||||||
|
|
||||||
|
public record UpcomingRenewal
|
||||||
|
{
|
||||||
|
public required DateTime RenewalDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record IssuedRenewal
|
||||||
|
{
|
||||||
|
public required DateTime IssuedDate { get; set; }
|
||||||
|
public required DateTime DueDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PastDueRenewal
|
||||||
|
{
|
||||||
|
public required DateTime SuspensionDate { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,214 @@
|
|||||||
|
// ReSharper disable InconsistentNaming
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Api.Billing.Models.Responses.Organizations;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Stripe;
|
||||||
|
using FreeTrialWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.FreeTrialWarning;
|
||||||
|
using InactiveSubscriptionWarning =
|
||||||
|
Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.InactiveSubscriptionWarning;
|
||||||
|
using ResellerRenewalWarning =
|
||||||
|
Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.ResellerRenewalWarning;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Queries.Organizations;
|
||||||
|
|
||||||
|
public interface IOrganizationWarningsQuery
|
||||||
|
{
|
||||||
|
Task<OrganizationWarningsResponse> Run(
|
||||||
|
Organization organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrganizationWarningsQuery(
|
||||||
|
ICurrentContext currentContext,
|
||||||
|
IProviderRepository providerRepository,
|
||||||
|
IStripeAdapter stripeAdapter,
|
||||||
|
ISubscriberService subscriberService) : IOrganizationWarningsQuery
|
||||||
|
{
|
||||||
|
public async Task<OrganizationWarningsResponse> Run(
|
||||||
|
Organization organization)
|
||||||
|
{
|
||||||
|
var response = new OrganizationWarningsResponse();
|
||||||
|
|
||||||
|
var subscription =
|
||||||
|
await subscriberService.GetSubscription(organization,
|
||||||
|
new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] });
|
||||||
|
|
||||||
|
if (subscription == null)
|
||||||
|
{
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.FreeTrial = await GetFreeTrialWarning(organization, subscription);
|
||||||
|
|
||||||
|
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
|
response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription);
|
||||||
|
|
||||||
|
response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FreeTrialWarning?> GetFreeTrialWarning(
|
||||||
|
Organization organization,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
if (!await currentContext.EditSubscription(organization.Id))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription is not
|
||||||
|
{
|
||||||
|
Status: StripeConstants.SubscriptionStatus.Trialing,
|
||||||
|
TrialEnd: not null,
|
||||||
|
Customer: not null
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = subscription.Customer;
|
||||||
|
|
||||||
|
var hasPaymentMethod =
|
||||||
|
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||||
|
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
|
||||||
|
customer.Metadata.ContainsKey(StripeConstants.MetadataKeys.BraintreeCustomerId);
|
||||||
|
|
||||||
|
if (hasPaymentMethod)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
var remainingTrialDays = (int)Math.Ceiling((subscription.TrialEnd.Value - now).TotalDays);
|
||||||
|
|
||||||
|
return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarning(
|
||||||
|
Organization organization,
|
||||||
|
Provider? provider,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
if (organization.Enabled ||
|
||||||
|
subscription.Status is not StripeConstants.SubscriptionStatus.Unpaid
|
||||||
|
and not StripeConstants.SubscriptionStatus.Canceled)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider != null)
|
||||||
|
{
|
||||||
|
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await currentContext.OrganizationOwner(organization.Id))
|
||||||
|
{
|
||||||
|
return subscription.Status switch
|
||||||
|
{
|
||||||
|
StripeConstants.SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning
|
||||||
|
{
|
||||||
|
Resolution = "add_payment_method"
|
||||||
|
},
|
||||||
|
StripeConstants.SubscriptionStatus.Canceled => new InactiveSubscriptionWarning
|
||||||
|
{
|
||||||
|
Resolution = "resubscribe"
|
||||||
|
},
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarning(
|
||||||
|
Provider? provider,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
if (provider is not
|
||||||
|
{
|
||||||
|
Type: ProviderType.Reseller
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.CollectionMethod != StripeConstants.CollectionMethod.SendInvoice)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
// ReSharper disable once ConvertIfStatementToSwitchStatement
|
||||||
|
if (subscription is
|
||||||
|
{
|
||||||
|
Status: StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active,
|
||||||
|
LatestInvoice: null or { Status: StripeConstants.InvoiceStatus.Paid }
|
||||||
|
} && (subscription.CurrentPeriodEnd - now).TotalDays <= 14)
|
||||||
|
{
|
||||||
|
return new ResellerRenewalWarning
|
||||||
|
{
|
||||||
|
Type = "upcoming",
|
||||||
|
Upcoming = new ResellerRenewalWarning.UpcomingRenewal
|
||||||
|
{
|
||||||
|
RenewalDate = subscription.CurrentPeriodEnd
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription is
|
||||||
|
{
|
||||||
|
Status: StripeConstants.SubscriptionStatus.Active,
|
||||||
|
LatestInvoice: { Status: StripeConstants.InvoiceStatus.Open, DueDate: not null }
|
||||||
|
} && subscription.LatestInvoice.DueDate > now)
|
||||||
|
{
|
||||||
|
return new ResellerRenewalWarning
|
||||||
|
{
|
||||||
|
Type = "issued",
|
||||||
|
Issued = new ResellerRenewalWarning.IssuedRenewal
|
||||||
|
{
|
||||||
|
IssuedDate = subscription.LatestInvoice.Created,
|
||||||
|
DueDate = subscription.LatestInvoice.DueDate.Value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once InvertIf
|
||||||
|
if (subscription.Status == StripeConstants.SubscriptionStatus.PastDue)
|
||||||
|
{
|
||||||
|
var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions
|
||||||
|
{
|
||||||
|
Query = $"subscription:'{subscription.Id}' status:'open'"
|
||||||
|
});
|
||||||
|
|
||||||
|
var earliestOverdueInvoice = openInvoices
|
||||||
|
.Where(invoice => invoice.DueDate != null && invoice.DueDate < now)
|
||||||
|
.MinBy(invoice => invoice.Created);
|
||||||
|
|
||||||
|
if (earliestOverdueInvoice != null)
|
||||||
|
{
|
||||||
|
return new ResellerRenewalWarning
|
||||||
|
{
|
||||||
|
Type = "past_due",
|
||||||
|
PastDue = new ResellerRenewalWarning.PastDueRenewal
|
||||||
|
{
|
||||||
|
SuspensionDate = earliestOverdueInvoice.DueDate!.Value.AddDays(30)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
11
src/Api/Billing/Registrations.cs
Normal file
11
src/Api/Billing/Registrations.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Bit.Api.Billing.Queries.Organizations;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing;
|
||||||
|
|
||||||
|
public static class Registrations
|
||||||
|
{
|
||||||
|
public static void AddBillingQueries(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddTransient<IOrganizationWarningsQuery, OrganizationWarningsQuery>();
|
||||||
|
}
|
||||||
|
}
|
@ -27,6 +27,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
|
|||||||
using Bit.Core.Tools.Entities;
|
using Bit.Core.Tools.Entities;
|
||||||
using Bit.Core.Vault.Entities;
|
using Bit.Core.Vault.Entities;
|
||||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||||
|
using Bit.Api.Billing;
|
||||||
using Bit.Core.AdminConsole.Services.NoopImplementations;
|
using Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Auth.Identity.TokenProviders;
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
@ -184,6 +185,8 @@ public class Startup
|
|||||||
services.AddImportServices();
|
services.AddImportServices();
|
||||||
services.AddPhishingDomainServices(globalSettings);
|
services.AddPhishingDomainServices(globalSettings);
|
||||||
|
|
||||||
|
services.AddBillingQueries();
|
||||||
|
|
||||||
// Authorization Handlers
|
// Authorization Handlers
|
||||||
services.AddAuthorizationHandlers();
|
services.AddAuthorizationHandlers();
|
||||||
|
|
||||||
|
@ -0,0 +1,128 @@
|
|||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Services;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||||
|
|
||||||
|
public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand
|
||||||
|
{
|
||||||
|
|
||||||
|
private readonly IOrganizationService _organizationService;
|
||||||
|
private readonly ICollectionRepository _collectionRepository;
|
||||||
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||||
|
private readonly IDataProtector _dataProtector;
|
||||||
|
private readonly IGlobalSettings _globalSettings;
|
||||||
|
private readonly IPolicyService _policyService;
|
||||||
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
|
||||||
|
public InitPendingOrganizationCommand(
|
||||||
|
IOrganizationService organizationService,
|
||||||
|
ICollectionRepository collectionRepository,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||||
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
IPolicyService policyService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_organizationService = organizationService;
|
||||||
|
_collectionRepository = collectionRepository;
|
||||||
|
_organizationRepository = organizationRepository;
|
||||||
|
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||||
|
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
_policyService = policyService;
|
||||||
|
_organizationUserRepository = organizationUserRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken)
|
||||||
|
{
|
||||||
|
await ValidateSignUpPoliciesAsync(user.Id);
|
||||||
|
|
||||||
|
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
|
||||||
|
if (orgUser == null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("User invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenValid = ValidateInviteToken(orgUser, user, emailToken);
|
||||||
|
|
||||||
|
if (!tokenValid)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
var org = await _organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (org.Enabled)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Organization is already enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.Status != OrganizationStatusType.Pending)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Organization is not on a Pending status.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(org.PublicKey))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Organization already has a Public Key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(org.PrivateKey))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Organization already has a Private Key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
org.Enabled = true;
|
||||||
|
org.Status = OrganizationStatusType.Created;
|
||||||
|
org.PublicKey = publicKey;
|
||||||
|
org.PrivateKey = privateKey;
|
||||||
|
|
||||||
|
await _organizationService.UpdateAsync(org);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(collectionName))
|
||||||
|
{
|
||||||
|
// give the owner Can Manage access over the default collection
|
||||||
|
List<CollectionAccessSelection> defaultOwnerAccess =
|
||||||
|
[new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }];
|
||||||
|
|
||||||
|
var defaultCollection = new Collection
|
||||||
|
{
|
||||||
|
Name = collectionName,
|
||||||
|
OrganizationId = org.Id
|
||||||
|
};
|
||||||
|
await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ValidateSignUpPoliciesAsync(Guid ownerId)
|
||||||
|
{
|
||||||
|
var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg);
|
||||||
|
if (anySingleOrgPolicies)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("You may not create an organization. You belong to an organization " +
|
||||||
|
"which has a policy that prohibits you from being a member of any other organization.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ValidateInviteToken(OrganizationUser orgUser, User user, string emailToken)
|
||||||
|
{
|
||||||
|
var tokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
|
||||||
|
_orgUserInviteTokenDataFactory, emailToken, orgUser);
|
||||||
|
|
||||||
|
return tokenValid;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
namespace Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
|
||||||
|
public interface IInitPendingOrganizationCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method must target a disabled Organization that has null keys and status as 'Pending'.
|
||||||
|
/// </remarks>
|
||||||
|
Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken);
|
||||||
|
}
|
@ -48,14 +48,8 @@ public interface IOrganizationService
|
|||||||
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
||||||
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
|
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
|
||||||
/// <summary>
|
|
||||||
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// This method must target a disabled Organization that has null keys and status as 'Pending'.
|
|
||||||
/// </remarks>
|
|
||||||
Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken);
|
|
||||||
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
|
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
|
||||||
|
Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd);
|
||||||
|
|
||||||
void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
||||||
void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
@ -7,7 +8,8 @@ namespace Bit.Core.AdminConsole.Services;
|
|||||||
|
|
||||||
public interface IProviderService
|
public interface IProviderService
|
||||||
{
|
{
|
||||||
Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null);
|
Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource = null);
|
||||||
Task UpdateAsync(Provider provider, bool updateBilling = false);
|
Task UpdateAsync(Provider provider, bool updateBilling = false);
|
||||||
|
|
||||||
Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite);
|
Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite);
|
||||||
|
@ -13,7 +13,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
|||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
@ -31,12 +30,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
|||||||
using Bit.Core.Platform.Push;
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Tokens;
|
|
||||||
using Bit.Core.Tools.Enums;
|
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 Microsoft.AspNetCore.DataProtection;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
@ -77,8 +74,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
|
||||||
private readonly IDataProtector _dataProtector;
|
|
||||||
|
|
||||||
public OrganizationService(
|
public OrganizationService(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -112,9 +107,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
|
||||||
IDataProtectionProvider dataProtectionProvider
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
@ -149,8 +142,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
|
||||||
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
||||||
@ -1067,7 +1058,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
organization: organization,
|
organization: organization,
|
||||||
initOrganization: initOrganization));
|
initOrganization: initOrganization));
|
||||||
|
|
||||||
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
public async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
int seatsToAdd)
|
int seatsToAdd)
|
||||||
{
|
{
|
||||||
@ -1921,71 +1912,4 @@ public class OrganizationService : IOrganizationService
|
|||||||
SalesAssistedTrialStarted = salesAssistedTrialStarted,
|
SalesAssistedTrialStarted = salesAssistedTrialStarted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken)
|
|
||||||
{
|
|
||||||
await ValidateSignUpPoliciesAsync(user.Id);
|
|
||||||
|
|
||||||
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
|
|
||||||
if (orgUser == null)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("User invalid.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
|
|
||||||
var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
|
|
||||||
_orgUserInviteTokenDataFactory, emailToken, orgUser);
|
|
||||||
|
|
||||||
var tokenValid = newTokenValid ||
|
|
||||||
CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, orgUser.Id,
|
|
||||||
_globalSettings);
|
|
||||||
|
|
||||||
if (!tokenValid)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Invalid token.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var org = await GetOrgById(organizationId);
|
|
||||||
|
|
||||||
if (org.Enabled)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Organization is already enabled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (org.Status != OrganizationStatusType.Pending)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Organization is not on a Pending status.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(org.PublicKey))
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Organization already has a Public Key.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(org.PrivateKey))
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Organization already has a Private Key.");
|
|
||||||
}
|
|
||||||
|
|
||||||
org.Enabled = true;
|
|
||||||
org.Status = OrganizationStatusType.Created;
|
|
||||||
org.PublicKey = publicKey;
|
|
||||||
org.PrivateKey = privateKey;
|
|
||||||
|
|
||||||
await UpdateAsync(org);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(collectionName))
|
|
||||||
{
|
|
||||||
// give the owner Can Manage access over the default collection
|
|
||||||
List<CollectionAccessSelection> defaultOwnerAccess =
|
|
||||||
[new CollectionAccessSelection { Id = organizationUserId, HidePasswords = false, ReadOnly = false, Manage = true }];
|
|
||||||
|
|
||||||
var defaultCollection = new Collection
|
|
||||||
{
|
|
||||||
Name = collectionName,
|
|
||||||
OrganizationId = org.Id
|
|
||||||
};
|
|
||||||
await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
@ -7,7 +8,7 @@ namespace Bit.Core.AdminConsole.Services.NoopImplementations;
|
|||||||
|
|
||||||
public class NoopProviderService : IProviderService
|
public class NoopProviderService : IProviderService
|
||||||
{
|
{
|
||||||
public Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null) => throw new NotImplementedException();
|
public Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) => throw new NotImplementedException();
|
||||||
|
|
||||||
public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException();
|
public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException();
|
||||||
|
|
||||||
|
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
public static class StripeConstants
|
public static class StripeConstants
|
||||||
{
|
{
|
||||||
|
public static class Prices
|
||||||
|
{
|
||||||
|
public const string StoragePlanPersonal = "personal-storage-gb-annually";
|
||||||
|
}
|
||||||
public static class AutomaticTaxStatus
|
public static class AutomaticTaxStatus
|
||||||
{
|
{
|
||||||
public const string Failed = "failed";
|
public const string Failed = "failed";
|
||||||
@ -42,10 +46,12 @@ public static class StripeConstants
|
|||||||
{
|
{
|
||||||
public const string Draft = "draft";
|
public const string Draft = "draft";
|
||||||
public const string Open = "open";
|
public const string Open = "open";
|
||||||
|
public const string Paid = "paid";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class MetadataKeys
|
public static class MetadataKeys
|
||||||
{
|
{
|
||||||
|
public const string BraintreeCustomerId = "btCustomerId";
|
||||||
public const string InvoiceApproved = "invoice_approved";
|
public const string InvoiceApproved = "invoice_approved";
|
||||||
public const string OrganizationId = "organizationId";
|
public const string OrganizationId = "organizationId";
|
||||||
public const string ProviderId = "providerId";
|
public const string ProviderId = "providerId";
|
||||||
|
@ -38,7 +38,7 @@ public record Families2019Plan : Plan
|
|||||||
HasPremiumAccessOption = true;
|
HasPremiumAccessOption = true;
|
||||||
|
|
||||||
StripePlanId = "personal-org-annually";
|
StripePlanId = "personal-org-annually";
|
||||||
StripeStoragePlanId = "storage-gb-annually";
|
StripeStoragePlanId = "personal-storage-gb-annually";
|
||||||
StripePremiumAccessPlanId = "personal-org-premium-access-annually";
|
StripePremiumAccessPlanId = "personal-org-premium-access-annually";
|
||||||
BasePrice = 12;
|
BasePrice = 12;
|
||||||
AdditionalStoragePricePerGb = 4;
|
AdditionalStoragePricePerGb = 4;
|
||||||
|
@ -37,7 +37,7 @@ public record FamiliesPlan : Plan
|
|||||||
HasAdditionalStorageOption = true;
|
HasAdditionalStorageOption = true;
|
||||||
|
|
||||||
StripePlanId = "2020-families-org-annually";
|
StripePlanId = "2020-families-org-annually";
|
||||||
StripeStoragePlanId = "storage-gb-annually";
|
StripeStoragePlanId = "personal-storage-gb-annually";
|
||||||
BasePrice = 40;
|
BasePrice = 40;
|
||||||
AdditionalStoragePricePerGb = 4;
|
AdditionalStoragePricePerGb = 4;
|
||||||
|
|
||||||
|
@ -79,10 +79,12 @@ public interface IProviderBillingService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="provider">The <see cref="Provider"/> to create a Stripe customer for.</param>
|
/// <param name="provider">The <see cref="Provider"/> to create a Stripe customer for.</param>
|
||||||
/// <param name="taxInfo">The <see cref="TaxInfo"/> to use for calculating the customer's automatic tax.</param>
|
/// <param name="taxInfo">The <see cref="TaxInfo"/> to use for calculating the customer's automatic tax.</param>
|
||||||
|
/// <param name="tokenizedPaymentSource">The <see cref="TokenizedPaymentSource"/> (ex. Credit Card) to attach to the customer.</param>
|
||||||
/// <returns>The newly created <see cref="Stripe.Customer"/> for the <paramref name="provider"/>.</returns>
|
/// <returns>The newly created <see cref="Stripe.Customer"/> for the <paramref name="provider"/>.</returns>
|
||||||
Task<Customer> SetupCustomer(
|
Task<Customer> SetupCustomer(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
TaxInfo taxInfo);
|
TaxInfo taxInfo,
|
||||||
|
TokenizedPaymentSource tokenizedPaymentSource = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// For use during the provider setup process, this method starts a Stripe <see cref="Stripe.Subscription"/> for the given <paramref name="provider"/>.
|
/// For use during the provider setup process, this method starts a Stripe <see cref="Stripe.Subscription"/> for the given <paramref name="provider"/>.
|
||||||
|
@ -313,7 +313,7 @@ public class PremiumUserBillingService(
|
|||||||
{
|
{
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
Price = "storage-gb-annually",
|
Price = StripeConstants.Prices.StoragePlanPersonal,
|
||||||
Quantity = storage
|
Quantity = storage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -149,6 +149,8 @@ public static class FeatureFlagKeys
|
|||||||
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
|
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
|
||||||
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
|
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
|
||||||
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
|
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
|
||||||
|
public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup";
|
||||||
|
public const string UseOrganizationWarningsService = "use-organization-warnings-service";
|
||||||
|
|
||||||
/* Data Insights and Reporting Team */
|
/* Data Insights and Reporting Team */
|
||||||
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationSponsorships;
|
||||||
|
|
||||||
|
namespace Bit.Core.Models.Api.Response.OrganizationSponsorships;
|
||||||
|
|
||||||
|
public class OrganizationSponsorshipInvitesResponseModel : ResponseModel
|
||||||
|
{
|
||||||
|
public OrganizationSponsorshipInvitesResponseModel(OrganizationSponsorshipData sponsorshipData, string obj = "organizationSponsorship") : base(obj)
|
||||||
|
{
|
||||||
|
if (sponsorshipData == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(sponsorshipData));
|
||||||
|
}
|
||||||
|
|
||||||
|
SponsoringOrganizationUserId = sponsorshipData.SponsoringOrganizationUserId;
|
||||||
|
FriendlyName = sponsorshipData.FriendlyName;
|
||||||
|
OfferedToEmail = sponsorshipData.OfferedToEmail;
|
||||||
|
PlanSponsorshipType = sponsorshipData.PlanSponsorshipType;
|
||||||
|
LastSyncDate = sponsorshipData.LastSyncDate;
|
||||||
|
ValidUntil = sponsorshipData.ValidUntil;
|
||||||
|
ToDelete = sponsorshipData.ToDelete;
|
||||||
|
IsAdminInitiated = sponsorshipData.IsAdminInitiated;
|
||||||
|
Notes = sponsorshipData.Notes;
|
||||||
|
CloudSponsorshipRemoved = sponsorshipData.CloudSponsorshipRemoved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid SponsoringOrganizationUserId { get; set; }
|
||||||
|
public string FriendlyName { get; set; }
|
||||||
|
public string OfferedToEmail { get; set; }
|
||||||
|
public PlanSponsorshipType PlanSponsorshipType { get; set; }
|
||||||
|
public DateTime? LastSyncDate { get; set; }
|
||||||
|
public DateTime? ValidUntil { get; set; }
|
||||||
|
public bool ToDelete { get; set; }
|
||||||
|
public bool IsAdminInitiated { get; set; }
|
||||||
|
public string Notes { get; set; }
|
||||||
|
public bool CloudSponsorshipRemoved { get; set; }
|
||||||
|
}
|
@ -193,6 +193,7 @@ public static class OrganizationServiceCollectionExtensions
|
|||||||
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
|
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
|
||||||
services.AddScoped<IInviteUsersPasswordManagerValidator, InviteUsersPasswordManagerValidator>();
|
services.AddScoped<IInviteUsersPasswordManagerValidator, InviteUsersPasswordManagerValidator>();
|
||||||
services.AddScoped<IInviteUsersEnvironmentValidator, InviteUsersEnvironmentValidator>();
|
services.AddScoped<IInviteUsersEnvironmentValidator, InviteUsersEnvironmentValidator>();
|
||||||
|
services.AddScoped<IInitPendingOrganizationCommand, InitPendingOrganizationCommand>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
||||||
|
@ -15,7 +15,8 @@ public class CreateSponsorshipCommand(
|
|||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
IOrganizationService organizationService) : ICreateSponsorshipCommand
|
IOrganizationService organizationService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository) : ICreateSponsorshipCommand
|
||||||
{
|
{
|
||||||
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(
|
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(
|
||||||
Organization sponsoringOrganization,
|
Organization sponsoringOrganization,
|
||||||
@ -47,11 +48,12 @@ public class CreateSponsorshipCommand(
|
|||||||
throw new BadRequestException("Only confirmed users can sponsor other organizations.");
|
throw new BadRequestException("Only confirmed users can sponsor other organizations.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingOrgSponsorship = await organizationSponsorshipRepository
|
var sponsorships =
|
||||||
.GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id);
|
await organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrganization.Id);
|
||||||
if (existingOrgSponsorship?.SponsoredOrganizationId != null)
|
var existingSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName == friendlyName);
|
||||||
|
if (existingSponsorship != null)
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Can only sponsor one organization per Organization User.");
|
return existingSponsorship;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdminInitiated)
|
if (isAdminInitiated)
|
||||||
@ -70,15 +72,37 @@ public class CreateSponsorshipCommand(
|
|||||||
Notes = notes
|
Notes = notes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isAdminInitiated)
|
||||||
|
{
|
||||||
|
var existingOrgSponsorship = await organizationSponsorshipRepository
|
||||||
|
.GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id);
|
||||||
|
if (existingOrgSponsorship?.SponsoredOrganizationId != null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Can only sponsor one organization per Organization User.");
|
||||||
|
}
|
||||||
|
|
||||||
if (existingOrgSponsorship != null)
|
if (existingOrgSponsorship != null)
|
||||||
{
|
{
|
||||||
// Replace existing invalid offer with our new sponsorship offer
|
|
||||||
sponsorship.Id = existingOrgSponsorship.Id;
|
sponsorship.Id = existingOrgSponsorship.Id;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isAdminInitiated && sponsoringOrganization.Seats.HasValue)
|
if (isAdminInitiated && sponsoringOrganization.Seats.HasValue)
|
||||||
{
|
{
|
||||||
await organizationService.AutoAddSeatsAsync(sponsoringOrganization, 1);
|
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id);
|
||||||
|
var availableSeats = sponsoringOrganization.Seats.Value - occupiedSeats;
|
||||||
|
|
||||||
|
if (availableSeats <= 0)
|
||||||
|
{
|
||||||
|
var newSeatsRequired = 1;
|
||||||
|
var (canScale, failureReason) = await organizationService.CanScaleAsync(sponsoringOrganization, newSeatsRequired);
|
||||||
|
if (!canScale)
|
||||||
|
{
|
||||||
|
throw new BadRequestException(failureReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
await organizationService.AutoAddSeatsAsync(sponsoringOrganization, newSeatsRequired);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
|
@ -39,7 +39,7 @@ public class AzurePhishingDomainStorageService
|
|||||||
var content = await streamReader.ReadToEndAsync();
|
var content = await streamReader.ReadToEndAsync();
|
||||||
|
|
||||||
return [.. content
|
return [.. content
|
||||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(line => line.Trim())
|
.Select(line => line.Trim())
|
||||||
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))];
|
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))];
|
||||||
}
|
}
|
||||||
|
@ -92,7 +92,7 @@ public class CloudPhishingDomainDirectQuery : ICloudPhishingDomainQuery
|
|||||||
}
|
}
|
||||||
|
|
||||||
return content
|
return content
|
||||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(line => line.Trim())
|
.Select(line => line.Trim())
|
||||||
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#"))
|
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Services;
|
|||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models;
|
using Bit.Core.Auth.Models;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
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;
|
using Bit.Core.Billing.Services;
|
||||||
@ -45,7 +46,6 @@ namespace Bit.Core.Services;
|
|||||||
public class UserService : UserManager<User>, IUserService, IDisposable
|
public class UserService : UserManager<User>, IUserService, IDisposable
|
||||||
{
|
{
|
||||||
private const string PremiumPlanId = "premium-annually";
|
private const string PremiumPlanId = "premium-annually";
|
||||||
private const string StoragePlanId = "storage-gb-annually";
|
|
||||||
|
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ICipherRepository _cipherRepository;
|
private readonly ICipherRepository _cipherRepository;
|
||||||
@ -1106,12 +1106,12 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb,
|
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb,
|
||||||
StoragePlanId);
|
StripeConstants.Prices.StoragePlanPersonal);
|
||||||
await _referenceEventService.RaiseEventAsync(
|
await _referenceEventService.RaiseEventAsync(
|
||||||
new ReferenceEvent(ReferenceEventType.AdjustStorage, user, _currentContext)
|
new ReferenceEvent(ReferenceEventType.AdjustStorage, user, _currentContext)
|
||||||
{
|
{
|
||||||
Storage = storageAdjustmentGb,
|
Storage = storageAdjustmentGb,
|
||||||
PlanName = StoragePlanId,
|
PlanName = StripeConstants.Prices.StoragePlanPersonal,
|
||||||
});
|
});
|
||||||
await SaveUserAsync(user);
|
await SaveUserAsync(user);
|
||||||
return secret;
|
return secret;
|
||||||
|
@ -103,6 +103,7 @@ public static class EntityFrameworkServiceCollectionExtensions
|
|||||||
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
||||||
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
||||||
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
|
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
|
||||||
|
services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>();
|
||||||
|
|
||||||
if (selfHosted)
|
if (selfHosted)
|
||||||
{
|
{
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
using Bit.Api.AdminConsole.Authorization;
|
using AutoFixture.Xunit2;
|
||||||
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@ -25,4 +27,42 @@ public class HttpContextExtensionsTests
|
|||||||
await callback.ReceivedWithAnyArgs(1).Invoke();
|
await callback.ReceivedWithAnyArgs(1).Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineAutoData("orgId")]
|
||||||
|
[InlineAutoData("organizationId")]
|
||||||
|
public void GetOrganizationId_GivenValidParameter_ReturnsOrganizationId(string paramName, Guid orgId)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
Request = { RouteValues = new RouteValueDictionary
|
||||||
|
{
|
||||||
|
{ "userId", "someGuid" },
|
||||||
|
{ paramName, orgId.ToString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = httpContext.GetOrganizationId();
|
||||||
|
Assert.Equal(orgId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineAutoData("orgId")]
|
||||||
|
[InlineAutoData("organizationId")]
|
||||||
|
[InlineAutoData("missingParameter")]
|
||||||
|
public void GetOrganizationId_GivenMissingOrInvalidGuid_Throws(string paramName)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
Request = { RouteValues = new RouteValueDictionary
|
||||||
|
{
|
||||||
|
{ "userId", "someGuid" },
|
||||||
|
{ paramName, "invalidGuid" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var exception = Assert.Throws<InvalidOperationException>(() => httpContext.GetOrganizationId());
|
||||||
|
Assert.Equal(HttpContextExtensions.NoOrgIdError, exception.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ using Bit.Core.Context;
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
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;
|
||||||
@ -13,6 +14,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 NSubstitute;
|
using NSubstitute;
|
||||||
|
using NSubstitute.ReturnsExtensions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Bit.Api.Test.Billing.Controllers;
|
namespace Bit.Api.Test.Billing.Controllers;
|
||||||
@ -146,4 +148,80 @@ public class OrganizationSponsorshipsControllerTests
|
|||||||
.DidNotReceiveWithAnyArgs()
|
.DidNotReceiveWithAnyArgs()
|
||||||
.RemoveSponsorshipAsync(default);
|
.RemoveSponsorshipAsync(default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task GetSponsoredOrganizations_OrganizationNotFound_ThrowsNotFound(
|
||||||
|
Guid sponsoringOrgId,
|
||||||
|
SutProvider<OrganizationSponsorshipsController> sutProvider)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(sponsoringOrgId).ReturnsNull();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||||
|
sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrgId));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||||
|
.DidNotReceiveWithAnyArgs()
|
||||||
|
.GetManyBySponsoringOrganizationAsync(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task GetSponsoredOrganizations_NotOrganizationOwner_ThrowsNotFound(
|
||||||
|
Organization sponsoringOrg,
|
||||||
|
SutProvider<OrganizationSponsorshipsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(sponsoringOrg.Id).Returns(false);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(sponsoringOrg.Id).Returns(false);
|
||||||
|
|
||||||
|
// Create a CurrentContextOrganization with ManageUsers set to false
|
||||||
|
var currentContextOrg = new CurrentContextOrganization
|
||||||
|
{
|
||||||
|
Id = sponsoringOrg.Id,
|
||||||
|
Permissions = new Permissions { ManageUsers = false }
|
||||||
|
};
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization> { currentContextOrg });
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
|
||||||
|
sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrg.Id));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||||
|
.DidNotReceiveWithAnyArgs()
|
||||||
|
.GetManyBySponsoringOrganizationAsync(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task GetSponsoredOrganizations_Success_ReturnsSponsorships(
|
||||||
|
Organization sponsoringOrg,
|
||||||
|
List<OrganizationSponsorship> sponsorships,
|
||||||
|
SutProvider<OrganizationSponsorshipsController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(sponsoringOrg.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(sponsoringOrg.Id).Returns(false);
|
||||||
|
|
||||||
|
// Create a CurrentContextOrganization from the sponsoringOrg
|
||||||
|
var currentContextOrg = new CurrentContextOrganization
|
||||||
|
{
|
||||||
|
Id = sponsoringOrg.Id,
|
||||||
|
Permissions = new Permissions { ManageUsers = true }
|
||||||
|
};
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization> { currentContextOrg });
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||||
|
.GetManyBySponsoringOrganizationAsync(sponsoringOrg.Id).Returns(sponsorships);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrg.Id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(sponsorships.Count, result.Data.Count());
|
||||||
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
||||||
|
.GetManyBySponsoringOrganizationAsync(sponsoringOrg.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,315 @@
|
|||||||
|
using Bit.Api.Billing.Queries.Organizations;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ReturnsExtensions;
|
||||||
|
using Stripe;
|
||||||
|
using Stripe.TestHelpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Api.Test.Billing.Queries.Organizations;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class OrganizationWarningsQueryTests
|
||||||
|
{
|
||||||
|
private static readonly string[] _requiredExpansions = ["customer", "latest_invoice", "test_clock"];
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_NoSubscription_NoWarnings(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.ReturnsNull();
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
FreeTrial: null,
|
||||||
|
InactiveSubscription: null,
|
||||||
|
ResellerRenewal: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_FreeTrialWarning(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Trialing,
|
||||||
|
TrialEnd = now.AddDays(7),
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||||
|
Metadata = new Dictionary<string, string>()
|
||||||
|
},
|
||||||
|
TestClock = new TestClock
|
||||||
|
{
|
||||||
|
FrozenTime = now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
FreeTrial.RemainingTrialDays: 7
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_InactiveSubscriptionWarning_ContactProvider(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Unpaid
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||||
|
.Returns(new Provider());
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
InactiveSubscription.Resolution: "contact_provider"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_InactiveSubscriptionWarning_AddPaymentMethod(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Unpaid
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
InactiveSubscription.Resolution: "add_payment_method"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_InactiveSubscriptionWarning_Resubscribe(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Canceled
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
InactiveSubscription.Resolution: "resubscribe"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_InactiveSubscriptionWarning_ContactOwner(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Unpaid
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(false);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
InactiveSubscription.Resolution: "contact_owner"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_ResellerRenewalWarning_Upcoming(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Active,
|
||||||
|
CurrentPeriodEnd = now.AddDays(10),
|
||||||
|
TestClock = new TestClock
|
||||||
|
{
|
||||||
|
FrozenTime = now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||||
|
.Returns(new Provider
|
||||||
|
{
|
||||||
|
Type = ProviderType.Reseller
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
ResellerRenewal.Type: "upcoming"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(now.AddDays(10), response.ResellerRenewal.Upcoming!.RenewalDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_ResellerRenewalWarning_Issued(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Active,
|
||||||
|
LatestInvoice = new Invoice
|
||||||
|
{
|
||||||
|
Status = StripeConstants.InvoiceStatus.Open,
|
||||||
|
DueDate = now.AddDays(30),
|
||||||
|
Created = now
|
||||||
|
},
|
||||||
|
TestClock = new TestClock
|
||||||
|
{
|
||||||
|
FrozenTime = now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||||
|
.Returns(new Provider
|
||||||
|
{
|
||||||
|
Type = ProviderType.Reseller
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
ResellerRenewal.Type: "issued"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(now, response.ResellerRenewal.Issued!.IssuedDate);
|
||||||
|
Assert.Equal(now.AddDays(30), response.ResellerRenewal.Issued!.DueDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_ResellerRenewalWarning_PastDue(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
const string subscriptionId = "subscription_id";
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Id = subscriptionId,
|
||||||
|
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||||
|
Status = StripeConstants.SubscriptionStatus.PastDue,
|
||||||
|
TestClock = new TestClock
|
||||||
|
{
|
||||||
|
FrozenTime = now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||||
|
.Returns(new Provider
|
||||||
|
{
|
||||||
|
Type = ProviderType.Reseller
|
||||||
|
});
|
||||||
|
|
||||||
|
var dueDate = now.AddDays(-10);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().InvoiceSearchAsync(Arg.Is<InvoiceSearchOptions>(options =>
|
||||||
|
options.Query == $"subscription:'{subscriptionId}' status:'open'")).Returns([
|
||||||
|
new Invoice { DueDate = dueDate, Created = dueDate.AddDays(-30) }
|
||||||
|
]);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
ResellerRenewal.Type: "past_due"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(dueDate.AddDays(30), response.ResellerRenewal.PastDue!.SuspensionDate);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,169 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Bit.Test.Common.Fakes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class InitPendingOrganizationCommandTests
|
||||||
|
{
|
||||||
|
|
||||||
|
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory = Substitute.For<IOrgUserInviteTokenableFactory>();
|
||||||
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>();
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_Success(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
{
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.PrivateKey = null;
|
||||||
|
org.PublicKey = null;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var organizationServcie = sutProvider.GetDependency<IOrganizationService>();
|
||||||
|
var collectionRepository = sutProvider.GetDependency<ICollectionRepository>();
|
||||||
|
|
||||||
|
await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token);
|
||||||
|
|
||||||
|
await organizationRepository.Received().GetByIdAsync(orgId);
|
||||||
|
await organizationServcie.Received().UpdateAsync(org);
|
||||||
|
await collectionRepository.DidNotReceiveWithAnyArgs().CreateAsync(default);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_With_CollectionName_Success(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, string collectionName, OrganizationUser orgUser)
|
||||||
|
{
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.PrivateKey = null;
|
||||||
|
org.PublicKey = null;
|
||||||
|
org.Id = orgId;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var organizationServcie = sutProvider.GetDependency<IOrganizationService>();
|
||||||
|
var collectionRepository = sutProvider.GetDependency<ICollectionRepository>();
|
||||||
|
|
||||||
|
await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, collectionName, token);
|
||||||
|
|
||||||
|
await organizationRepository.Received().GetByIdAsync(orgId);
|
||||||
|
await organizationServcie.Received().UpdateAsync(org);
|
||||||
|
|
||||||
|
await collectionRepository.Received().CreateAsync(
|
||||||
|
Arg.Any<Collection>(),
|
||||||
|
Arg.Is<List<CollectionAccessSelection>>(l => l == null),
|
||||||
|
Arg.Is<List<CollectionAccessSelection>>(l => l.Any(i => i.Manage == true)));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_When_Organization_Is_Enabled(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
|
||||||
|
{
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.Enabled = true;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||||
|
|
||||||
|
Assert.Equal("Organization is already enabled.", exception.Message);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_When_Organization_Is_Not_Pending(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.Status = Enums.OrganizationStatusType.Created;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||||
|
|
||||||
|
Assert.Equal("Organization is not on a Pending status.", exception.Message);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_When_Organization_Has_Public_Key(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
|
||||||
|
{
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.PublicKey = publicKey;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||||
|
|
||||||
|
Assert.Equal("Organization already has a Public Key.", exception.Message);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Init_Organization_When_Organization_Has_Private_Key(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||||
|
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||||
|
|
||||||
|
org.PublicKey = null;
|
||||||
|
org.PrivateKey = privateKey;
|
||||||
|
org.Enabled = false;
|
||||||
|
|
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||||
|
|
||||||
|
Assert.Equal("Organization already has a Private Key.", exception.Message);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreateToken(OrganizationUser orgUser, Guid orgUserId, SutProvider<InitPendingOrganizationCommand> sutProvider)
|
||||||
|
{
|
||||||
|
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
|
||||||
|
sutProvider.Create();
|
||||||
|
|
||||||
|
_orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(orgUser)
|
||||||
|
{
|
||||||
|
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||||
|
});
|
||||||
|
|
||||||
|
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
|
||||||
|
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(orgUser);
|
||||||
|
|
||||||
|
return protectedToken;
|
||||||
|
}
|
||||||
|
}
|
@ -168,6 +168,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
|||||||
});
|
});
|
||||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(sponsoringOrgUser.UserId.Value);
|
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(sponsoringOrgUser.UserId.Value);
|
||||||
|
|
||||||
|
// Setup for checking available seats
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||||
|
.Returns(0);
|
||||||
|
|
||||||
|
|
||||||
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||||
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null);
|
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null);
|
||||||
@ -293,6 +298,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
|||||||
{
|
{
|
||||||
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
||||||
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
||||||
|
sponsoringOrg.Seats = 10;
|
||||||
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||||
|
|
||||||
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
||||||
@ -311,6 +317,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Setup for checking available seats - organization has plenty of seats
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||||
|
.Returns(5);
|
||||||
|
|
||||||
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||||
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
|
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
|
||||||
|
|
||||||
@ -331,5 +342,121 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
|||||||
|
|
||||||
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
||||||
.CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
|
.CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
|
||||||
|
|
||||||
|
// Verify we didn't need to add seats
|
||||||
|
await sutProvider.GetDependency<IOrganizationService>().DidNotReceive()
|
||||||
|
.AutoAddSeatsAsync(Arg.Any<Organization>(), Arg.Any<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(OrganizationUserType.Admin)]
|
||||||
|
[BitAutoData(OrganizationUserType.Owner)]
|
||||||
|
public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_AutoscalesWhenNeeded(
|
||||||
|
OrganizationUserType organizationUserType,
|
||||||
|
Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail,
|
||||||
|
string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider<CreateSponsorshipCommand> sutProvider)
|
||||||
|
{
|
||||||
|
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
||||||
|
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
||||||
|
sponsoringOrg.Seats = 10;
|
||||||
|
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
||||||
|
sutProvider.GetDependency<IOrganizationSponsorshipRepository>().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo =>
|
||||||
|
{
|
||||||
|
var sponsorship = callInfo.Arg<OrganizationSponsorship>();
|
||||||
|
sponsorship.Id = sponsorshipId;
|
||||||
|
});
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(currentUserId);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns([
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = sponsoringOrg.Id,
|
||||||
|
Permissions = new Permissions { ManageUsers = true },
|
||||||
|
Type = organizationUserType
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Setup for checking available seats - organization has no available seats
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||||
|
.Returns(10);
|
||||||
|
|
||||||
|
// Setup for checking if can scale
|
||||||
|
sutProvider.GetDependency<IOrganizationService>()
|
||||||
|
.CanScaleAsync(sponsoringOrg, 1)
|
||||||
|
.Returns((true, ""));
|
||||||
|
|
||||||
|
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||||
|
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
|
||||||
|
|
||||||
|
|
||||||
|
var expectedSponsorship = new OrganizationSponsorship
|
||||||
|
{
|
||||||
|
Id = sponsorshipId,
|
||||||
|
SponsoringOrganizationId = sponsoringOrg.Id,
|
||||||
|
SponsoringOrganizationUserId = sponsoringOrgUser.Id,
|
||||||
|
FriendlyName = friendlyName,
|
||||||
|
OfferedToEmail = sponsoredEmail,
|
||||||
|
PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise,
|
||||||
|
IsAdminInitiated = true,
|
||||||
|
Notes = notes
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(SponsorshipValidator(expectedSponsorship, actual));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
||||||
|
.CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
|
||||||
|
|
||||||
|
// Verify we needed to add seats
|
||||||
|
await sutProvider.GetDependency<IOrganizationService>().Received(1)
|
||||||
|
.AutoAddSeatsAsync(sponsoringOrg, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(OrganizationUserType.Admin)]
|
||||||
|
[BitAutoData(OrganizationUserType.Owner)]
|
||||||
|
public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_ThrowsWhenCannotAutoscale(
|
||||||
|
OrganizationUserType organizationUserType,
|
||||||
|
Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail,
|
||||||
|
string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider<CreateSponsorshipCommand> sutProvider)
|
||||||
|
{
|
||||||
|
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
||||||
|
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
||||||
|
sponsoringOrg.Seats = 10;
|
||||||
|
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
||||||
|
sutProvider.GetDependency<IOrganizationSponsorshipRepository>().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo =>
|
||||||
|
{
|
||||||
|
var sponsorship = callInfo.Arg<OrganizationSponsorship>();
|
||||||
|
sponsorship.Id = sponsorshipId;
|
||||||
|
});
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(currentUserId);
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns([
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = sponsoringOrg.Id,
|
||||||
|
Permissions = new Permissions { ManageUsers = true },
|
||||||
|
Type = organizationUserType
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Setup for checking available seats - organization has no available seats
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||||
|
.Returns(10);
|
||||||
|
|
||||||
|
// Setup for checking if can scale - cannot scale
|
||||||
|
var failureReason = "Seat limit has been reached.";
|
||||||
|
sutProvider.GetDependency<IOrganizationService>()
|
||||||
|
.CanScaleAsync(sponsoringOrg, 1)
|
||||||
|
.Returns((false, failureReason));
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
|
sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||||
|
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes));
|
||||||
|
|
||||||
|
Assert.Equal(failureReason, exception.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user