diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 70084a66b687..fcbe65d2d3ad 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -18,6 +18,8 @@ using Bit.Core.Models.Data.Organizations.Policies; using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; +using Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -50,6 +52,8 @@ public class OrganizationsController : Controller private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; private readonly ILicensingService _licensingService; + private readonly IOrganizationSignUpCommand _organizationSignUpCommand; + private readonly IOrganizationUpgradePlanCommand _organizationUpgradePlanCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -70,7 +74,9 @@ public OrganizationsController( ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, IFeatureService featureService, GlobalSettings globalSettings, - ILicensingService licensingService) + ILicensingService licensingService, + IOrganizationSignUpCommand organizationSignUpCommand, + IOrganizationUpgradePlanCommand organizationUpgradePlanCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -91,6 +97,8 @@ public OrganizationsController( _featureService = featureService; _globalSettings = globalSettings; _licensingService = licensingService; + _organizationSignUpCommand = organizationSignUpCommand; + _organizationUpgradePlanCommand = organizationUpgradePlanCommand; } [HttpGet("{id}")] @@ -241,7 +249,12 @@ public async Task Post([FromBody] OrganizationCreateR } var organizationSignup = model.ToOrganizationSignup(user); - var result = await _organizationService.SignUpAsync(organizationSignup); + + var result = !_featureService.IsEnabled(FeatureFlagKeys.SecretManagerGaBilling, _currentContext) && + !model.UseSecretsManager + ? await _organizationService.SignUpAsync(organizationSignup) + : await _organizationSignUpCommand.SignUpAsync(organizationSignup); + return new OrganizationResponseModel(result.Item1); } @@ -306,7 +319,11 @@ public async Task PostUpgrade(string id, [FromBody] Organi throw new NotFoundException(); } - var result = await _organizationService.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()); + var result = !_featureService.IsEnabled(FeatureFlagKeys.SecretManagerGaBilling, _currentContext) && + !model.UseSecretsManager + ? await _organizationService.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()) + : await _organizationUpgradePlanCommand.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()); + return new PaymentResponseModel { Success = result.Item1, PaymentIntentClientSecret = result.Item2 }; } @@ -487,7 +504,7 @@ public async Task ApiKey(string id, [FromBody] Organization if (model.Type == OrganizationApiKeyType.BillingSync || model.Type == OrganizationApiKeyType.Scim) { // Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types - var plan = StaticStore.GetPlan(organization.PlanType); + var plan = StaticStore.GetPasswordManagerPlan(organization.PlanType); if (plan.Product != ProductType.Enterprise) { throw new NotFoundException(); diff --git a/src/Api/Controllers/PlansController.cs b/src/Api/Controllers/PlansController.cs index d738e60cfb20..a7ae7c0b3060 100644 --- a/src/Api/Controllers/PlansController.cs +++ b/src/Api/Controllers/PlansController.cs @@ -19,12 +19,30 @@ public PlansController(ITaxRateRepository taxRateRepository) [HttpGet("")] [AllowAnonymous] public ListResponseModel Get() + { + var data = StaticStore.PasswordManagerPlans; + var responses = data.Select(plan => new PlanResponseModel(plan)); + return new ListResponseModel(responses); + } + + [HttpGet("all")] + [AllowAnonymous] + public ListResponseModel GetAllPlans() { var data = StaticStore.Plans; var responses = data.Select(plan => new PlanResponseModel(plan)); return new ListResponseModel(responses); } + [HttpGet("sm-plans")] + [AllowAnonymous] + public ListResponseModel GetSecretsManagerPlans() + { + var data = StaticStore.SecretManagerPlans; + var responses = data.Select(plan => new PlanResponseModel(plan)); + return new ListResponseModel(responses); + } + [HttpGet("sales-tax-rates")] public async Task> GetTaxRates() { diff --git a/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 3e4602179503..a08de38bd65b 100644 --- a/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -40,6 +40,12 @@ public class OrganizationCreateRequestModel : IValidatableObject [StringLength(2)] public string BillingAddressCountry { get; set; } public int? MaxAutoscaleSeats { get; set; } + [Range(0, int.MaxValue)] + public int? AdditionalSmSeats { get; set; } + [Range(0, int.MaxValue)] + public int? AdditionalServiceAccount { get; set; } + [Required] + public bool UseSecretsManager { get; set; } public virtual OrganizationSignup ToOrganizationSignup(User user) { @@ -58,6 +64,9 @@ public virtual OrganizationSignup ToOrganizationSignup(User user) BillingEmail = BillingEmail, BusinessName = BusinessName, CollectionName = CollectionName, + AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(0), + AdditionalServiceAccount = AdditionalServiceAccount.GetValueOrDefault(0), + UseSecretsManager = UseSecretsManager, TaxInfo = new TaxInfo { TaxIdNumber = TaxIdNumber, diff --git a/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs index fb2666cc1ed0..1350372d43e1 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs @@ -13,7 +13,13 @@ public class OrganizationUpgradeRequestModel public int AdditionalSeats { get; set; } [Range(0, 99)] public short? AdditionalStorageGb { get; set; } + [Range(0, int.MaxValue)] + public int? AdditionalSmSeats { get; set; } + [Range(0, int.MaxValue)] + public int? AdditionalServiceAccount { get; set; } public bool PremiumAccessAddon { get; set; } + [Required] + public bool UseSecretsManager { get; set; } public string BillingAddressCountry { get; set; } public string BillingAddressPostalCode { get; set; } public OrganizationKeysRequestModel Keys { get; set; } @@ -24,6 +30,9 @@ public OrganizationUpgrade ToOrganizationUpgrade() { AdditionalSeats = AdditionalSeats, AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(), + AdditionalServiceAccount = AdditionalServiceAccount.GetValueOrDefault(0), + AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(0), + UseSecretsManager = UseSecretsManager, BusinessName = BusinessName, Plan = PlanType, PremiumAccessAddon = PremiumAccessAddon, diff --git a/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs index 8dbfc4b61c6d..4a4edf5d5e8d 100644 --- a/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs @@ -26,7 +26,7 @@ public OrganizationResponseModel(Organization organization, string obj = "organi BusinessCountry = organization.BusinessCountry; BusinessTaxNumber = organization.BusinessTaxNumber; BillingEmail = organization.BillingEmail; - Plan = new PlanResponseModel(StaticStore.Plans.FirstOrDefault(plan => plan.Type == organization.PlanType)); + Plan = new PlanResponseModel(StaticStore.PasswordManagerPlans.FirstOrDefault(plan => plan.Type == organization.PlanType)); PlanType = organization.PlanType; Seats = organization.Seats; MaxAutoscaleSeats = organization.MaxAutoscaleSeats; diff --git a/src/Api/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/Models/Response/ProfileOrganizationResponseModel.cs index 215fc7a23a70..bb57be69d896 100644 --- a/src/Api/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/Models/Response/ProfileOrganizationResponseModel.cs @@ -54,7 +54,7 @@ public ProfileOrganizationResponseModel(OrganizationUserOrganizationDetails orga FamilySponsorshipAvailable = FamilySponsorshipFriendlyName == null && StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) .UsersCanSponsor(organization); - PlanProductType = StaticStore.GetPlan(organization.PlanType).Product; + PlanProductType = StaticStore.GetPasswordManagerPlan(organization.PlanType).Product; FamilySponsorshipLastSyncDate = organization.FamilySponsorshipLastSyncDate; FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete; FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil; diff --git a/src/Api/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/Models/Response/ProfileProviderOrganizationResponseModel.cs index d08e3b1ea4f6..9dccad8930c5 100644 --- a/src/Api/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -42,6 +42,6 @@ public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails UserId = organization.UserId?.ToString(); ProviderId = organization.ProviderId?.ToString(); ProviderName = organization.ProviderName; - PlanProductType = StaticStore.GetPlan(organization.PlanType).Product; + PlanProductType = StaticStore.GetPasswordManagerPlan(organization.PlanType).Product; } } diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index fff638bf2045..8562bd0a2438 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -417,7 +417,7 @@ await _transactionRepository.CreateAsync(new Transaction // org if (ids.Item1.HasValue) { - if (subscription.Items.Any(i => StaticStore.Plans.Any(p => p.StripePlanId == i.Plan.Id))) + if (subscription.Items.Any(i => StaticStore.PasswordManagerPlans.Any(p => p.StripePlanId == i.Plan.Id))) { await _organizationService.EnableAsync(ids.Item1.Value, subscription.CurrentPeriodEnd); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8ff378d00cb7..ff2bf8ac4346 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -36,6 +36,7 @@ public static class FeatureFlagKeys public const string DisplayEuEnvironment = "display-eu-environment"; public const string DisplayLowKdfIterationWarning = "display-kdf-iteration-warning"; public const string TrustedDeviceEncryption = "trusted-device-encryption"; + public const string SecretManagerGaBilling = "sm-ga-billing"; public static List GetAllKeys() { diff --git a/src/Core/Enums/BitwardenProductType..cs b/src/Core/Enums/BitwardenProductType..cs new file mode 100644 index 000000000000..d0c358671f74 --- /dev/null +++ b/src/Core/Enums/BitwardenProductType..cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Enums; + +public enum BitwardenProductType : byte +{ + [Display(Name = "Password Manager")] + PasswordManager = 0, + [Display(Name = "Secrets Manager")] + SecretsManager = 1, +} diff --git a/src/Core/Models/Business/OrganizationUpgrade.cs b/src/Core/Models/Business/OrganizationUpgrade.cs index b77a9d012c2e..22bc287e44b3 100644 --- a/src/Core/Models/Business/OrganizationUpgrade.cs +++ b/src/Core/Models/Business/OrganizationUpgrade.cs @@ -12,4 +12,7 @@ public class OrganizationUpgrade public TaxInfo TaxInfo { get; set; } public string PublicKey { get; set; } public string PrivateKey { get; set; } + public int? AdditionalSmSeats { get; set; } + public int? AdditionalServiceAccount { get; set; } + public bool UseSecretsManager { get; set; } } diff --git a/src/Core/Models/Business/SubscriptionCreateOptions.cs b/src/Core/Models/Business/SubscriptionCreateOptions.cs index 4964a625c8c4..c94252a535f5 100644 --- a/src/Core/Models/Business/SubscriptionCreateOptions.cs +++ b/src/Core/Models/Business/SubscriptionCreateOptions.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.Enums; using Stripe; namespace Bit.Core.Models.Business; @@ -54,6 +55,80 @@ public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan pl DefaultTaxRates = new List { taxInfo.StripeTaxRateId }; } } + + public OrganizationSubscriptionOptionsBase(Organization org, IEnumerable plans, TaxInfo taxInfo, int additionalSeats + , int additionalStorageGb, bool premiumAccessAddon, int additionalSmSeats = 0, int additionalServiceAccount = 0) + { + Items = new List(); + Metadata = new Dictionary + { + [org.GatewayIdField()] = org.Id.ToString() + }; + + foreach (var plan in plans) + { + if (plan.StripePlanId != null) + { + Items.Add(new SubscriptionItemOptions + { + Plan = plan.StripePlanId, + Quantity = 1 + }); + } + + if (additionalSeats > 0 && plan.StripeSeatPlanId != null && plan.BitwardenProduct == BitwardenProductType.PasswordManager) + { + Items.Add(new SubscriptionItemOptions + { + Plan = plan.StripeSeatPlanId, + Quantity = additionalSeats + }); + } + + if (additionalStorageGb > 0 && plan.BitwardenProduct == BitwardenProductType.PasswordManager) + { + Items.Add(new SubscriptionItemOptions + { + Plan = plan.StripeStoragePlanId, + Quantity = additionalStorageGb + }); + } + + if (additionalSmSeats > 0 && plan.StripePlanId != null && plan.BitwardenProduct == BitwardenProductType.SecretsManager) + { + Items.Add(new SubscriptionItemOptions + { + Plan = plan.StripePlanId, + Quantity = additionalSmSeats + }); + } + + if (additionalServiceAccount > 0 && plan.StripePlanId != null && plan.BitwardenProduct == BitwardenProductType.SecretsManager) + { + Items.Add(new SubscriptionItemOptions + { + Plan = plan.StripePlanId, + Quantity = additionalServiceAccount + }); + } + + if (premiumAccessAddon && plan.StripePremiumAccessPlanId != null && plan.BitwardenProduct == BitwardenProductType.PasswordManager) + { + Items.Add(new SubscriptionItemOptions + { + Plan = plan.StripePremiumAccessPlanId, + Quantity = 1 + }); + } + } + + + if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId)) + { + DefaultTaxRates = new List { taxInfo.StripeTaxRateId }; + } + + } } public class OrganizationPurchaseSubscriptionOptions : OrganizationSubscriptionOptionsBase @@ -67,6 +142,15 @@ public OrganizationPurchaseSubscriptionOptions( OffSession = true; TrialPeriodDays = plan.TrialPeriodDays; } + + public OrganizationPurchaseSubscriptionOptions(Organization org, IEnumerable plans, TaxInfo taxInfo, int additionalSeats = 0 + , int additionalStorageGb = 0, bool premiumAccessAddon = false + , int additionalSmSeats = 0, int additionalServiceAccount = 0) : + base(org, plans, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon, additionalSmSeats, additionalServiceAccount) + { + OffSession = true; + TrialPeriodDays = plans.FirstOrDefault().TrialPeriodDays; + } } public class OrganizationUpgradeSubscriptionOptions : OrganizationSubscriptionOptionsBase diff --git a/src/Core/Models/StaticStore/Plan.cs b/src/Core/Models/StaticStore/Plan.cs index 4a0313790dc4..56ffe9b2a705 100644 --- a/src/Core/Models/StaticStore/Plan.cs +++ b/src/Core/Models/StaticStore/Plan.cs @@ -52,4 +52,9 @@ public class Plan public decimal SeatPrice { get; set; } public decimal AdditionalStoragePricePerGb { get; set; } public decimal PremiumAccessOptionPrice { get; set; } + public decimal? AdditionalPricePerServiceAccount { get; set; } + public short? BaseServiceAccount { get; set; } + public short? MaxServiceAccount { get; set; } + public bool HasAdditionalServiceAccountOption { get; set; } + public BitwardenProductType BitwardenProduct { get; set; } } diff --git a/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/Interface/IOrganizationUpgradePlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/Interface/IOrganizationUpgradePlanCommand.cs new file mode 100644 index 000000000000..28f88a771085 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/Interface/IOrganizationUpgradePlanCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Models.Business; + +namespace Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; + +public interface IOrganizationUpgradePlanCommand +{ + Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade); +} diff --git a/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/Interface/IOrganizationUpgradeQuery.cs b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/Interface/IOrganizationUpgradeQuery.cs new file mode 100644 index 000000000000..6c4483fdc666 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/Interface/IOrganizationUpgradeQuery.cs @@ -0,0 +1,14 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; + +public interface IOrganizationUpgradeQuery +{ + Plan ExistingPlan(PlanType planType); + + List NewPlans(PlanType planType); + + Task GetOrgById(Guid id); +} diff --git a/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/Interface/IValidateUpgradeCommand.cs b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/Interface/IValidateUpgradeCommand.cs new file mode 100644 index 000000000000..7e2b77c8b754 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/Interface/IValidateUpgradeCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; + +public interface IValidateUpgradeCommand +{ + void ValidatePlan(Plan newPlan, Plan existingPlan); + Task ValidateSeatsAsync(Organization organization, Plan passwordManagerPlan, OrganizationUpgrade upgrade); + Task ValidateSmSeatsAsync(Organization organization, Plan newPlan, OrganizationUpgrade upgrade); + Task ValidateServiceAccountAsync(Organization organization, Plan newPlan, OrganizationUpgrade upgrade); + Task ValidateCollectionsAsync(Organization organization, Plan newPlan); + Task ValidateGroupsAsync(Organization organization, Plan newPlan); + Task ValidatePoliciesAsync(Organization organization, Plan newPlan); + Task ValidateSsoAsync(Organization organization, Plan newPlan); + Task ValidateKeyConnectorAsync(Organization organization, Plan newPlan); + Task ValidateResetPasswordAsync(Organization organization, Plan newPlan); + Task ValidateScimAsync(Organization organization, Plan newPlan); + Task ValidateCustomPermissionsAsync(Organization organization, Plan newPlan); + +} diff --git a/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradePlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradePlanCommand.cs new file mode 100644 index 000000000000..c3121993fd01 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradePlanCommand.cs @@ -0,0 +1,165 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; +using Bit.Core.OrganizationFeatures.OrganizationSignUp; +using Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; + +namespace Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade; + +public class OrganizationUpgradePlanCommand : IOrganizationUpgradePlanCommand +{ + private readonly IOrganizationUpgradeQuery _organizationUpgradeQuery; + private readonly IValidateUpgradeCommand _validateUpgradeCommand; + private readonly IOrganizationService _organizationService; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + private readonly IPaymentService _paymentService; + private IOrganizationSignUpValidationStrategy _organizationSignUpValidationStrategy; + + public OrganizationUpgradePlanCommand( + IOrganizationUpgradeQuery organizationUpgradeQuery + , IValidateUpgradeCommand validateUpgradeCommand + , IOrganizationService organizationService + , IReferenceEventService referenceEventService + , ICurrentContext currentContext + , IPaymentService paymentService + , IOrganizationSignUpValidationStrategy organizationSignUpValidationStrategy) + { + _organizationUpgradeQuery = organizationUpgradeQuery; + _validateUpgradeCommand = validateUpgradeCommand; + _organizationService = organizationService; + _referenceEventService = referenceEventService; + _currentContext = currentContext; + _paymentService = paymentService; + _organizationSignUpValidationStrategy = organizationSignUpValidationStrategy; + } + + public async Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade) + { + var organization = await _organizationUpgradeQuery.GetOrgById(organizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + throw new BadRequestException("Your account has no payment method available."); + } + + var existingPlan = _organizationUpgradeQuery.ExistingPlan(organization.PlanType); + + var newPlans = _organizationUpgradeQuery.NewPlans(upgrade.Plan); + var passwordManagerPlan = newPlans.FirstOrDefault(x => x.BitwardenProduct == BitwardenProductType.PasswordManager); + var secretsManagerPlan = newPlans.FirstOrDefault(x => x.BitwardenProduct == BitwardenProductType.SecretsManager); + + _validateUpgradeCommand.ValidatePlan(passwordManagerPlan, existingPlan); + + foreach (var plan in newPlans) + { + _organizationSignUpValidationStrategy = plan.BitwardenProduct switch + { + BitwardenProductType.PasswordManager => new PasswordManagerSignUpValidationStrategy(), + _ => new SecretsManagerSignUpValidationStrategy() + }; + + _organizationSignUpValidationStrategy.Validate(plan, upgrade); + } + + await _validateUpgradeCommand.ValidateSeatsAsync(organization, passwordManagerPlan, upgrade); + await _validateUpgradeCommand.ValidateCollectionsAsync(organization, passwordManagerPlan); + await _validateUpgradeCommand.ValidateGroupsAsync(organization, passwordManagerPlan); + await _validateUpgradeCommand.ValidatePoliciesAsync(organization, passwordManagerPlan); + await _validateUpgradeCommand.ValidateSsoAsync(organization, passwordManagerPlan); + await _validateUpgradeCommand.ValidateKeyConnectorAsync(organization, passwordManagerPlan); + await _validateUpgradeCommand.ValidateResetPasswordAsync(organization, passwordManagerPlan); + await _validateUpgradeCommand.ValidateScimAsync(organization, passwordManagerPlan); + await _validateUpgradeCommand.ValidateCustomPermissionsAsync(organization, passwordManagerPlan); + await _validateUpgradeCommand.ValidateSmSeatsAsync(organization, secretsManagerPlan, upgrade); + await _validateUpgradeCommand.ValidateServiceAccountAsync(organization, secretsManagerPlan, upgrade); + + // TODO: Check storage? + + string paymentIntentClientSecret; + var success = true; + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, passwordManagerPlan, + upgrade.AdditionalStorageGb, upgrade.AdditionalSeats, upgrade.PremiumAccessAddon, upgrade.TaxInfo); + success = string.IsNullOrWhiteSpace(paymentIntentClientSecret); + } + else + { + // TODO: Update existing sub + throw new BadRequestException("You can only upgrade from the free plan. Contact support."); + } + + UpdateOrganizationProperties(organization, passwordManagerPlan, upgrade + , success, secretsManagerPlan); + + await _organizationService.ReplaceAndUpdateCacheAsync(organization); + if (success) + { + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.UpgradePlan, organization, _currentContext) + { + PlanName = passwordManagerPlan.Name, + PlanType = passwordManagerPlan.Type, + OldPlanName = existingPlan.Name, + OldPlanType = existingPlan.Type, + Seats = organization.Seats, + Storage = organization.MaxStorageGb, + SmSeats = organization.SmSeats, + ServiceAccounts = organization.SmServiceAccounts + }); + } + + return new Tuple(success, paymentIntentClientSecret); + } + + private static void UpdateOrganizationProperties(Organization organization, Plan passwordManagerPlan, OrganizationUpgrade upgrade + , bool success, Plan secretManagerPlan) + { + organization.BusinessName = upgrade.BusinessName; + organization.PlanType = passwordManagerPlan.Type; + organization.Seats = (short)(passwordManagerPlan.BaseSeats + upgrade.AdditionalSeats); + organization.MaxCollections = passwordManagerPlan.MaxCollections; + organization.UseGroups = passwordManagerPlan.HasGroups; + organization.UseDirectory = passwordManagerPlan.HasDirectory; + organization.UseEvents = passwordManagerPlan.HasEvents; + organization.UseTotp = passwordManagerPlan.HasTotp; + organization.Use2fa = passwordManagerPlan.Has2fa; + organization.UseApi = passwordManagerPlan.HasApi; + organization.SelfHost = passwordManagerPlan.HasSelfHost; + organization.UsePolicies = passwordManagerPlan.HasPolicies; + organization.MaxStorageGb = !passwordManagerPlan.BaseStorageGb.HasValue ? + null : (short)(passwordManagerPlan.BaseStorageGb.Value + upgrade.AdditionalStorageGb); + organization.UseGroups = passwordManagerPlan.HasGroups; + organization.UseDirectory = passwordManagerPlan.HasDirectory; + organization.UseEvents = passwordManagerPlan.HasEvents; + organization.UseTotp = passwordManagerPlan.HasTotp; + organization.Use2fa = passwordManagerPlan.Has2fa; + organization.UseApi = passwordManagerPlan.HasApi; + organization.UseSso = passwordManagerPlan.HasSso; + organization.UseKeyConnector = passwordManagerPlan.HasKeyConnector; + organization.UseScim = passwordManagerPlan.HasScim; + organization.UseResetPassword = passwordManagerPlan.HasResetPassword; + organization.SelfHost = passwordManagerPlan.HasSelfHost; + organization.UsersGetPremium = passwordManagerPlan.UsersGetPremium || upgrade.PremiumAccessAddon; + organization.UseCustomPermissions = passwordManagerPlan.HasCustomPermissions; + organization.Plan = passwordManagerPlan.Name; + organization.Enabled = success; + organization.PublicKey = upgrade.PublicKey; + organization.PrivateKey = upgrade.PrivateKey; + organization.SmSeats = (short)(secretManagerPlan.BaseSeats + upgrade.AdditionalSmSeats); + organization.SmServiceAccounts = (int)(secretManagerPlan.BaseServiceAccount + upgrade.AdditionalServiceAccount); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradeQuery.cs b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradeQuery.cs new file mode 100644 index 000000000000..9886463a54c8 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradeQuery.cs @@ -0,0 +1,31 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; +using Bit.Core.Repositories; +using Bit.Core.Utilities; + +namespace Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade; + +public class OrganizationUpgradeQuery : IOrganizationUpgradeQuery +{ + private readonly IOrganizationRepository _organizationRepository; + public OrganizationUpgradeQuery(IOrganizationRepository organizationRepository) + { + _organizationRepository = organizationRepository; + } + public Plan ExistingPlan(PlanType planType) + { + return StaticStore.Plans.FirstOrDefault(p => p.Type == planType); + } + + public List NewPlans(PlanType planType) + { + return StaticStore.Plans.Where(p => p.Type == planType && !p.Disabled).ToList(); + } + + public async Task GetOrgById(Guid id) + { + return await _organizationRepository.GetByIdAsync(id); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/ValidateUpgradeCommand.cs b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/ValidateUpgradeCommand.cs new file mode 100644 index 000000000000..918543f72299 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationPlanUpgrade/ValidateUpgradeCommand.cs @@ -0,0 +1,226 @@ +using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; +using Bit.Core.Repositories; + +namespace Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade; + +public class ValidateUpgradeCommand : IValidateUpgradeCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly ICollectionRepository _collectionRepository; + private readonly IGroupRepository _groupRepository; + private readonly IPolicyRepository _policyRepository; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly IOrganizationConnectionRepository _organizationConnectionRepository; + + public ValidateUpgradeCommand( + IOrganizationUserRepository organizationUserRepository + , ICollectionRepository collectionRepository + , IGroupRepository groupRepository + , IPolicyRepository policyRepository + , ISsoConfigRepository ssoConfigRepository + , IOrganizationConnectionRepository organizationConnectionRepository) + { + _organizationUserRepository = organizationUserRepository; + _collectionRepository = collectionRepository; + _groupRepository = groupRepository; + _policyRepository = policyRepository; + _ssoConfigRepository = ssoConfigRepository; + _organizationConnectionRepository = organizationConnectionRepository; + } + + public void ValidatePlan(Plan newPlan, Plan existingPlan) + { + if (existingPlan == null) + { + throw new BadRequestException("Existing plan not found."); + } + + if (newPlan == null) + { + throw new BadRequestException("Plan not found."); + } + + if (newPlan.Disabled) + { + throw new BadRequestException("Plan not found."); + } + + if (existingPlan.Type == newPlan.Type) + { + throw new BadRequestException("Organization is already on this plan."); + } + + if (existingPlan.UpgradeSortOrder >= newPlan.UpgradeSortOrder) + { + throw new BadRequestException("You cannot upgrade to this plan."); + } + + if (existingPlan.Type != PlanType.Free) + { + throw new BadRequestException("You can only upgrade from the free plan. Contact support."); + } + } + + public async Task ValidateSeatsAsync(Organization organization, Plan passwordManagerPlan, OrganizationUpgrade upgrade) + { + var newPlanSeats = (short)(passwordManagerPlan.BaseSeats + + (passwordManagerPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0)); + if (!organization.Seats.HasValue || organization.Seats.Value > newPlanSeats) + { + var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + if (occupiedSeats > newPlanSeats) + { + throw new BadRequestException($"Your organization currently has {occupiedSeats} password manager seats filled. " + + $"Your new plan only has ({newPlanSeats}) seats. Remove some users."); + } + } + } + + public async Task ValidateSmSeatsAsync(Organization organization, Plan newPlan, OrganizationUpgrade upgrade) + { + var newPlanSeats = (short)(newPlan.BaseSeats + (newPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSmSeats : 0)); + if (!organization.SmSeats.HasValue || organization.SmSeats.Value > newPlanSeats) + { + var occupiedSmSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + if (occupiedSmSeats > newPlanSeats) + { + throw new BadRequestException($"Your organization currently has {occupiedSmSeats} secrets manager seats filled. " + + $"Your new plan only has ({newPlanSeats}) seats. Remove some users."); + } + } + } + + public async Task ValidateServiceAccountAsync(Organization organization, Plan newPlan, OrganizationUpgrade upgrade) + { + if (newPlan.BaseServiceAccount != null) + { + var newPlanSeats = (short)(newPlan.BaseServiceAccount + (newPlan.HasAdditionalServiceAccountOption ? upgrade.AdditionalServiceAccount : 0)); + if (!organization.SmServiceAccounts.HasValue || organization.SmServiceAccounts.Value > newPlanSeats) + { + var occupiedServiceAccount = await _organizationUserRepository.GetOccupiedServiceAccountCountByOrganizationIdAsync(organization.Id); + if (occupiedServiceAccount > newPlanSeats) + { + throw new BadRequestException($"Your organization currently has {occupiedServiceAccount} service account seats filled. " + + $"Your new plan only has ({newPlanSeats}) service accounts. Remove some service accounts."); + } + } + } + } + + public async Task ValidateCollectionsAsync(Organization organization, Plan newPlan) + { + if (newPlan.MaxCollections.HasValue && (!organization.MaxCollections.HasValue || + organization.MaxCollections.Value > newPlan.MaxCollections.Value)) + { + var collectionCount = await _collectionRepository.GetCountByOrganizationIdAsync(organization.Id); + if (collectionCount > newPlan.MaxCollections.Value) + { + throw new BadRequestException($"Your organization currently has {collectionCount} collections. " + + $"Your new plan allows for a maximum of ({newPlan.MaxCollections.Value}) collections. " + + "Remove some collections."); + } + } + } + + public async Task ValidateGroupsAsync(Organization organization, Plan newPlan) + { + if (!newPlan.HasGroups && organization.UseGroups) + { + var groups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id); + if (groups.Any()) + { + throw new BadRequestException($"Your new plan does not allow the groups feature. " + + $"Remove your groups."); + } + } + } + + public async Task ValidatePoliciesAsync(Organization organization, Plan newPlan) + { + if (!newPlan.HasPolicies && organization.UsePolicies) + { + var policies = await _policyRepository.GetManyByOrganizationIdAsync(organization.Id); + if (policies.Any(p => p.Enabled)) + { + throw new BadRequestException($"Your new plan does not allow the policies feature. " + + $"Disable your policies."); + } + } + } + + public async Task ValidateSsoAsync(Organization organization, Plan newPlan) + { + if (!newPlan.HasSso && organization.UseSso) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id); + if (ssoConfig != null && ssoConfig.Enabled) + { + throw new BadRequestException($"Your new plan does not allow the SSO feature. " + + $"Disable your SSO configuration."); + } + } + } + + public async Task ValidateKeyConnectorAsync(Organization organization, Plan newPlan) + { + if (!newPlan.HasKeyConnector && organization.UseKeyConnector) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id); + if (ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.KeyConnector) + { + throw new BadRequestException($"Your new plan does not allow the Key Connector feature. " + + $"Disable your Key Connector configuration."); + } + } + } + + public async Task ValidateResetPasswordAsync(Organization organization, Plan newPlan) + { + if (!newPlan.HasResetPassword && organization.UseResetPassword) + { + var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled) + { + throw new BadRequestException("Your new plan does not allow the Password Reset feature. " + + "Disable your Password Reset policy."); + } + } + } + + public async Task ValidateScimAsync(Organization organization, Plan newPlan) + { + if (!newPlan.HasScim && organization.UseScim) + { + var scimConnections = await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organization.Id, + OrganizationConnectionType.Scim); + if (scimConnections != null && scimConnections.Any(c => c.GetConfig()?.Enabled == true)) + { + throw new BadRequestException("Your new plan does not allow the SCIM feature. " + + "Disable your SCIM configuration."); + } + } + } + + public async Task ValidateCustomPermissionsAsync(Organization organization, Plan newPlan) + { + if (!newPlan.HasCustomPermissions && organization.UseCustomPermissions) + { + var organizationCustomUsers = + await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id, + OrganizationUserType.Custom); + if (organizationCustomUsers.Any()) + { + throw new BadRequestException("Your new plan does not allow the Custom Permissions feature. " + + "Disable your Custom Permissions configuration."); + } + } + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 983fa3b3527e..6bf2582f54ff 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -11,6 +11,10 @@ using Bit.Core.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationLicenses; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; +using Bit.Core.OrganizationFeatures.OrganizationSignUp; +using Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -38,6 +42,8 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl services.AddOrganizationGroupCommands(); services.AddOrganizationLicenseCommandsQueries(); services.AddOrganizationDomainCommandsQueries(); + services.AddOrganizationSignUpCommands(); + services.AddOrganizationUpgradeCommands(); } private static void AddOrganizationConnectionCommands(this IServiceCollection services) @@ -117,4 +123,18 @@ private static void AddTokenizers(this IServiceCollection services) serviceProvider.GetRequiredService>>()) ); } + + private static void AddOrganizationSignUpCommands(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + + private static void AddOrganizationUpgradeCommands(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } } diff --git a/src/Core/OrganizationFeatures/OrganizationSignUp/Interfaces/IOrganizationSignUpCommand.cs b/src/Core/OrganizationFeatures/OrganizationSignUp/Interfaces/IOrganizationSignUpCommand.cs new file mode 100644 index 000000000000..42f145f0efe7 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSignUp/Interfaces/IOrganizationSignUpCommand.cs @@ -0,0 +1,10 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Business; + +namespace Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; + +public interface IOrganizationSignUpCommand +{ + Task> SignUpAsync(OrganizationSignup signup, + bool provider = false); +} diff --git a/src/Core/OrganizationFeatures/OrganizationSignUp/Interfaces/IOrganizationSignUpValidationStrategy.cs b/src/Core/OrganizationFeatures/OrganizationSignUp/Interfaces/IOrganizationSignUpValidationStrategy.cs new file mode 100644 index 000000000000..aa682a5e67b6 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSignUp/Interfaces/IOrganizationSignUpValidationStrategy.cs @@ -0,0 +1,8 @@ +using Bit.Core.Models.Business; + +namespace Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; + +public interface IOrganizationSignUpValidationStrategy +{ + void Validate(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); +} diff --git a/src/Core/OrganizationFeatures/OrganizationSignUp/OrganizationSignUpCommand.cs b/src/Core/OrganizationFeatures/OrganizationSignUp/OrganizationSignUpCommand.cs new file mode 100644 index 000000000000..135b4156cd38 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSignUp/OrganizationSignUpCommand.cs @@ -0,0 +1,170 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.OrganizationFeatures.OrganizationSignUp; + +public class OrganizationSignUpCommand : IOrganizationSignUpCommand +{ + private readonly IPaymentService _paymentService; + private readonly ICurrentContext _currentContext; + private readonly IOrganizationService _organizationService; + private readonly IPolicyService _policyService; + private readonly IReferenceEventService _referenceEventService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationSignUpValidationStrategy _organizationSignUpValidationStrategy; + + public OrganizationSignUpCommand( + IPaymentService paymentService + , ICurrentContext currentContext + , IOrganizationService organizationService + , IPolicyService policyService + , IReferenceEventService referenceEventService + , IOrganizationUserRepository organizationUserRepository + , IOrganizationSignUpValidationStrategy organizationSignUpValidationStrategy) + { + _paymentService = paymentService; + _currentContext = currentContext; + _organizationService = organizationService; + _policyService = policyService; + _referenceEventService = referenceEventService; + _organizationUserRepository = organizationUserRepository; + _organizationSignUpValidationStrategy = organizationSignUpValidationStrategy; + } + + public async Task> SignUpAsync(OrganizationSignup signup, + bool provider = false) + { + var plans = StaticStore.Plans.Where(p => p.Type == signup.Plan).ToList(); + + // if (!_featureService.IsEnabled(FeatureFlagKeys.SecretManagerGaBilling, _currentContext) && + // !signup.UseSecretsManager) + // { + // plans = StaticStore.PasswordManagerPlans.Where(p => p.Type == signup.Plan).ToList(); + // } + // else + // { + // + // } + foreach (var plan in plans) + { + if (plan is not { LegacyYear: null }) + { + throw new BadRequestException("Invalid plan selected."); + } + + if (plan.Disabled) + { + throw new BadRequestException("Plan not found."); + } + + ValidateOrganizationUpgradeParameters(plan, signup, _organizationSignUpValidationStrategy); + } + + if (!provider) + { + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(signup.Owner.Id, 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."); + } + } + + var passwordManagerPlan = plans.FirstOrDefault(x => x.BitwardenProduct == BitwardenProductType.PasswordManager); + var secretsManagerPlan = plans.FirstOrDefault(x => x.BitwardenProduct == BitwardenProductType.SecretsManager); + + var organization = new Organization + { + // Pre-generate the org id so that we can save it with the Stripe subscription.. + Id = CoreHelpers.GenerateComb(), + Name = signup.Name, + BillingEmail = signup.BillingEmail, + BusinessName = signup.BusinessName, + PlanType = passwordManagerPlan.Type, + Seats = (short)(passwordManagerPlan.BaseSeats + signup.AdditionalSeats), + MaxCollections = passwordManagerPlan.MaxCollections, + MaxStorageGb = !passwordManagerPlan.BaseStorageGb.HasValue ? + (short?)null : (short)(passwordManagerPlan.BaseStorageGb.Value + signup.AdditionalStorageGb), + UsePolicies = passwordManagerPlan.HasPolicies, + UseSso = passwordManagerPlan.HasSso, + UseGroups = passwordManagerPlan.HasGroups, + UseEvents = passwordManagerPlan.HasEvents, + UseDirectory = passwordManagerPlan.HasDirectory, + UseTotp = passwordManagerPlan.HasTotp, + Use2fa = passwordManagerPlan.Has2fa, + UseApi = passwordManagerPlan.HasApi, + UseResetPassword = passwordManagerPlan.HasResetPassword, + SelfHost = passwordManagerPlan.HasSelfHost, + UsersGetPremium = passwordManagerPlan.UsersGetPremium || signup.PremiumAccessAddon, + UseCustomPermissions = passwordManagerPlan.HasCustomPermissions, + UseScim = passwordManagerPlan.HasScim, + Plan = passwordManagerPlan.Name, + Gateway = null, + ReferenceData = signup.Owner.ReferenceData, + Enabled = true, + LicenseKey = CoreHelpers.SecureRandomString(20), + PublicKey = signup.PublicKey, + PrivateKey = signup.PrivateKey, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Status = OrganizationStatusType.Created, + SmSeats = (short)(secretsManagerPlan.BaseSeats + signup.AdditionalSmSeats), + SmServiceAccounts = + (short)(secretsManagerPlan.BaseServiceAccount + signup.AdditionalServiceAccount) + }; + + if (passwordManagerPlan.Type == PlanType.Free && !provider) + { + var adminCount = + await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id); + if (adminCount > 0) + { + throw new BadRequestException("You can only be an admin of one free organization."); + } + } + else if (passwordManagerPlan.Type != PlanType.Free) + { + await _paymentService.PurchaseOrganizationWithProductsAsync(organization, signup.PaymentMethodType.Value, + signup.PaymentToken, plans, signup.AdditionalStorageGb, signup.AdditionalSeats, + signup.PremiumAccessAddon, signup.TaxInfo, provider); + } + + var ownerId = provider ? default : signup.Owner.Id; + var returnValue = await _organizationService.SignUpAsync(organization, ownerId, signup.OwnerKey, signup.CollectionName, true); + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) + { + PlanName = passwordManagerPlan.Name, + PlanType = passwordManagerPlan.Type, + Seats = returnValue.Item1.Seats, + Storage = returnValue.Item1.MaxStorageGb, + SmSeats = returnValue.Item1.SmSeats, + ServiceAccounts = returnValue.Item1.SmServiceAccounts + }); + return returnValue; + } + + private static void ValidateOrganizationUpgradeParameters(Plan plan, OrganizationUpgrade upgrade + , IOrganizationSignUpValidationStrategy strategy) + { + strategy = plan.BitwardenProduct switch + { + BitwardenProductType.PasswordManager => new PasswordManagerSignUpValidationStrategy(), + _ => new SecretsManagerSignUpValidationStrategy() + }; + + strategy.Validate(plan, upgrade); + } + +} diff --git a/src/Core/OrganizationFeatures/OrganizationSignUp/PasswordManagerSignUpValidationStrategy.cs b/src/Core/OrganizationFeatures/OrganizationSignUp/PasswordManagerSignUpValidationStrategy.cs new file mode 100644 index 000000000000..275f3aeffe54 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSignUp/PasswordManagerSignUpValidationStrategy.cs @@ -0,0 +1,42 @@ +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; + +namespace Bit.Core.OrganizationFeatures.OrganizationSignUp; + +public class PasswordManagerSignUpValidationStrategy : IOrganizationSignUpValidationStrategy +{ + public void Validate(Plan plan, OrganizationUpgrade upgrade) + { + if (!plan.HasAdditionalStorageOption && upgrade.AdditionalStorageGb > 0) + { + throw new BadRequestException("Plan does not allow additional storage."); + } + + if (upgrade.AdditionalStorageGb < 0) + { + throw new BadRequestException("You can't subtract storage!"); + } + + if (plan.BaseSeats + upgrade.AdditionalSeats <= 0) + { + throw new BadRequestException("You do not have any seats!"); + } + + if (upgrade.AdditionalSeats < 0) + { + throw new BadRequestException("You can't subtract seats!"); + } + + switch (plan.HasAdditionalSeatsOption) + { + case false when upgrade.AdditionalSeats > 0: + throw new BadRequestException("Plan does not allow additional users."); + case true when plan.MaxAdditionalSeats.HasValue && + upgrade.AdditionalSeats > plan.MaxAdditionalSeats.Value: + throw new BadRequestException($"Selected plan allows a maximum of " + + $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); + } + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationSignUp/SecretsManagerSignUpValidationStrategy.cs b/src/Core/OrganizationFeatures/OrganizationSignUp/SecretsManagerSignUpValidationStrategy.cs new file mode 100644 index 000000000000..86e3efd482f2 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSignUp/SecretsManagerSignUpValidationStrategy.cs @@ -0,0 +1,44 @@ +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; + +namespace Bit.Core.OrganizationFeatures.OrganizationSignUp; + +public class SecretsManagerSignUpValidationStrategy : IOrganizationSignUpValidationStrategy +{ + public void Validate(Plan plan, OrganizationUpgrade upgrade) + { + if (!plan.HasAdditionalServiceAccountOption && upgrade.AdditionalServiceAccount > 0) + { + throw new BadRequestException("Plan does not allow additional service account."); + } + + if (upgrade.AdditionalServiceAccount < 0) + { + throw new BadRequestException("You can't subtract service account!"); + } + + if (plan.BaseSeats + upgrade.AdditionalSmSeats <= 0) + { + throw new BadRequestException("You do not have any seats!"); + } + + if (upgrade.AdditionalSmSeats < 0) + { + throw new BadRequestException("You can't subtract secrets manager seats!"); + } + + if (!plan.HasAdditionalSeatsOption && upgrade.AdditionalSmSeats > 0) + { + throw new BadRequestException("Plan does not allow additional users."); + } + + if (plan.HasAdditionalSeatsOption && plan.MaxAdditionalSeats.HasValue && + upgrade.AdditionalSmSeats > plan.MaxAdditionalSeats.Value) + { + throw new BadRequestException($"Selected plan allows a maximum of " + + $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); + } + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/CloudSyncSponsorshipsCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/CloudSyncSponsorshipsCommand.cs index d0569278bbaa..5c0f1474abd7 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/CloudSyncSponsorshipsCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/CloudSyncSponsorshipsCommand.cs @@ -54,7 +54,7 @@ await DoSyncAsync(sponsoringOrg, sponsorshipsData) : { var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(selfHostedSponsorship.PlanSponsorshipType)?.SponsoringProductType; if (requiredSponsoringProductType == null - || StaticStore.GetPlan(sponsoringOrg.PlanType).Product != requiredSponsoringProductType.Value) + || StaticStore.GetPasswordManagerPlan(sponsoringOrg.PlanType).Product != requiredSponsoringProductType.Value) { continue; // prevent unsupported sponsorships } diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs index 9230e7d13dbd..81a8bac96632 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs @@ -51,7 +51,7 @@ public async Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, var requiredSponsoredProductType = StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value)?.SponsoredProductType; if (requiredSponsoredProductType == null || sponsoredOrganization == null || - StaticStore.GetPlan(sponsoredOrganization.PlanType).Product != requiredSponsoredProductType.Value) + StaticStore.GetPasswordManagerPlan(sponsoredOrganization.PlanType).Product != requiredSponsoredProductType.Value) { throw new BadRequestException("Can only redeem sponsorship offer on families organizations."); } diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs index 3f2d7af5ebd5..af2f0af65d41 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs @@ -56,7 +56,7 @@ public async Task ValidateSponsorshipAsync(Guid sponsoredOrganizationId) return false; } - var sponsoringOrgPlan = Utilities.StaticStore.GetPlan(sponsoringOrganization.PlanType); + var sponsoringOrgPlan = Utilities.StaticStore.GetPasswordManagerPlan(sponsoringOrganization.PlanType); if (OrgDisabledForMoreThanGracePeriod(sponsoringOrganization) || sponsoredPlan.SponsoringProductType != sponsoringOrgPlan.Product || existingSponsorship.ToDelete || diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs index 69e6c3232c45..bfc33ebe879a 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs @@ -32,7 +32,7 @@ public async Task CreateSponsorshipAsync(Organization s var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(sponsorshipType)?.SponsoringProductType; if (requiredSponsoringProductType == null || sponsoringOrg == null || - StaticStore.GetPlan(sponsoringOrg.PlanType).Product != requiredSponsoringProductType.Value) + StaticStore.GetPasswordManagerPlan(sponsoringOrg.PlanType).Product != requiredSponsoringProductType.Value) { throw new BadRequestException("Specified Organization cannot sponsor other organizations."); } diff --git a/src/Core/Repositories/IOrganizationUserRepository.cs b/src/Core/Repositories/IOrganizationUserRepository.cs index 16d333f9e9fe..036c6cdebc7e 100644 --- a/src/Core/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/Repositories/IOrganizationUserRepository.cs @@ -40,4 +40,6 @@ Task GetDetailsByUserAsync(Guid userId, Gui Task RevokeAsync(Guid id); Task RestoreAsync(Guid id, OrganizationUserStatusType status); Task> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType); + Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId); + Task GetOccupiedServiceAccountCountByOrganizationIdAsync(Guid organizationId); } diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index b912bd92149a..d84c302d5244 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -22,6 +22,8 @@ Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, Payment Task> SignUpAsync(OrganizationSignup organizationSignup, bool provider = false); Task> SignUpAsync(OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, string privateKey); + Task> SignUpAsync(Organization organization, + Guid ownerId, string ownerKey, string collectionName, bool withPayment); Task DeleteAsync(Organization organization); Task EnableAsync(Guid organizationId, DateTime? expirationDate); Task DisableAsync(Guid organizationId, DateTime? expirationDate); diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 95c6f2590888..79913b6ddd23 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -11,6 +11,9 @@ public interface IPaymentService Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false); + Task PurchaseOrganizationWithProductsAsync(Organization org, PaymentMethodType paymentMethodType, + string paymentToken, IEnumerable plans, short additionalStorageGb, int additionalSeats, + bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, int additionalSmSeats = 0, int additionalServiceAccount = 0); Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship); Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship); Task UpgradeFreeOrganizationAsync(Organization org, Plan plan, diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 82d39a9d6de1..d0d31d028ae2 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -179,13 +179,13 @@ public async Task> UpgradePlanAsync(Guid organizationId, Org throw new BadRequestException("Your account has no payment method available."); } - var existingPlan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); + var existingPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); if (existingPlan == null) { throw new BadRequestException("Existing plan not found."); } - var newPlan = StaticStore.Plans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); + var newPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); if (newPlan == null) { throw new BadRequestException("Plan not found."); @@ -379,7 +379,7 @@ public async Task AdjustStorageAsync(Guid organizationId, short storageA throw new NotFoundException(); } - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); + var plan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); if (plan == null) { throw new BadRequestException("Existing plan not found."); @@ -437,7 +437,7 @@ private async Task UpdateAutoscalingAsync(Organization organization, int? maxAut throw new BadRequestException($"Cannot set max seat autoscaling below current seat count."); } - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); + var plan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); if (plan == null) { throw new BadRequestException("Existing plan not found."); @@ -489,7 +489,7 @@ private async Task AdjustSeatsAsync(Organization organization, int seatA throw new BadRequestException("No subscription found."); } - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); + var plan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); if (plan == null) { throw new BadRequestException("Existing plan not found."); @@ -607,7 +607,7 @@ public async Task VerifyBankAsync(Guid organizationId, int amount1, int amount2) public async Task> SignUpAsync(OrganizationSignup signup, bool provider = false) { - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan); + var plan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == signup.Plan); if (plan is not { LegacyYear: null }) { throw new BadRequestException("Invalid plan selected."); @@ -712,7 +712,7 @@ public async Task> SignUpAsync( } if (license.PlanType != PlanType.Custom && - StaticStore.Plans.FirstOrDefault(p => p.Type == license.PlanType && !p.Disabled) == null) + StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == license.PlanType && !p.Disabled) == null) { throw new BadRequestException("Plan not found."); } @@ -772,7 +772,7 @@ public async Task> SignUpAsync( return result; } - private async Task> SignUpAsync(Organization organization, + public async Task> SignUpAsync(Organization organization, Guid ownerId, string ownerKey, string collectionName, bool withPayment) { try @@ -2519,7 +2519,7 @@ static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(Organ public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted) { - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); + var plan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); if (plan is not { LegacyYear: null }) { throw new BadRequestException("Invalid plan selected."); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 40d905a8b173..1142696af471 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -199,9 +199,166 @@ public async Task PurchaseOrganizationAsync(Organization org, PaymentMet } } + public async Task PurchaseOrganizationWithProductsAsync(Organization org, PaymentMethodType paymentMethodType, + string paymentToken, IEnumerable plans, short additionalStorageGb, + int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, + int additionalSmSeats = 0, int additionalServiceAccount = 0) + { + Braintree.Customer braintreeCustomer = null; + string stipeCustomerSourceToken = null; + string stipeCustomerPaymentMethodId = null; + var stripeCustomerMetadata = new Dictionary(); + var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card || + paymentMethodType == PaymentMethodType.BankAccount; + + if (stripePaymentMethod && !string.IsNullOrWhiteSpace(paymentToken)) + { + if (paymentToken.StartsWith("pm_")) + { + stipeCustomerPaymentMethodId = paymentToken; + } + else + { + stipeCustomerSourceToken = paymentToken; + } + } + else if (paymentMethodType == PaymentMethodType.PayPal) + { + var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false); + var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest + { + PaymentMethodNonce = paymentToken, + Email = org.BillingEmail, + Id = org.BraintreeCustomerIdPrefix() + org.Id.ToString("N").ToLower() + randomSuffix, + CustomFields = new Dictionary + { + [org.BraintreeIdField()] = org.Id.ToString() + } + }); + + if (!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) + { + throw new GatewayException("Failed to create PayPal customer record."); + } + + braintreeCustomer = customerResult.Target; + stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); + } + else + { + throw new GatewayException("Payment method is not supported at this time."); + } + + if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode)) + { + var taxRateSearch = new TaxRate + { + Country = taxInfo.BillingAddressCountry, + PostalCode = taxInfo.BillingAddressPostalCode + }; + var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch); + + // should only be one tax rate per country/zip combo + var taxRate = taxRates.FirstOrDefault(); + if (taxRate != null) + { + taxInfo.StripeTaxRateId = taxRate.Id; + } + } + + var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plans, taxInfo, additionalSeats, additionalStorageGb + , premiumAccessAddon, additionalSmSeats, additionalServiceAccount); + + Stripe.Customer customer = null; + Stripe.Subscription subscription; + try + { + customer = await _stripeAdapter.CustomerCreateAsync(new Stripe.CustomerCreateOptions + { + Description = org.BusinessName, + Email = org.BillingEmail, + Source = stipeCustomerSourceToken, + PaymentMethod = stipeCustomerPaymentMethodId, + Metadata = stripeCustomerMetadata, + InvoiceSettings = new Stripe.CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = stipeCustomerPaymentMethodId, + CustomFields = new List + { + new Stripe.CustomerInvoiceSettingsCustomFieldOptions() + { + Name = org.SubscriberType(), + Value = GetFirstThirtyCharacters(org.SubscriberName()), + }, + }, + }, + Coupon = provider ? ProviderDiscountId : null, + Address = new Stripe.AddressOptions + { + Country = taxInfo.BillingAddressCountry, + PostalCode = taxInfo.BillingAddressPostalCode, + // Line1 is required in Stripe's API, suggestion in Docs is to use Business Name instead. + Line1 = taxInfo.BillingAddressLine1 ?? string.Empty, + Line2 = taxInfo.BillingAddressLine2, + City = taxInfo.BillingAddressCity, + State = taxInfo.BillingAddressState, + }, + TaxIdData = !taxInfo.HasTaxId ? null : new List + { + new Stripe.CustomerTaxIdDataOptions + { + Type = taxInfo.TaxIdType, + Value = taxInfo.TaxIdNumber, + }, + }, + }); + subCreateOptions.AddExpand("latest_invoice.payment_intent"); + subCreateOptions.Customer = customer.Id; + subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions); + if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null) + { + if (subscription.LatestInvoice.PaymentIntent.Status == "requires_payment_method") + { + await _stripeAdapter.SubscriptionCancelAsync(subscription.Id, new Stripe.SubscriptionCancelOptions()); + throw new GatewayException("Payment method was declined."); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating customer, walking back operation."); + if (customer != null) + { + await _stripeAdapter.CustomerDeleteAsync(customer.Id); + } + if (braintreeCustomer != null) + { + await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); + } + throw; + } + + org.Gateway = GatewayType.Stripe; + org.GatewayCustomerId = customer.Id; + org.GatewaySubscriptionId = subscription.Id; + + if (subscription.Status == "incomplete" && + subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action") + { + org.Enabled = false; + return subscription.LatestInvoice.PaymentIntent.ClientSecret; + } + else + { + org.Enabled = true; + org.ExpirationDate = subscription.CurrentPeriodEnd; + return null; + } + } + private async Task ChangeOrganizationSponsorship(Organization org, OrganizationSponsorship sponsorship, bool applySponsorship) { - var existingPlan = Utilities.StaticStore.GetPlan(org.PlanType); + var existingPlan = Utilities.StaticStore.GetPasswordManagerPlan(org.PlanType); var sponsoredPlan = sponsorship != null ? Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value) : null; diff --git a/src/Core/Tools/Models/Business/ReferenceEvent.cs b/src/Core/Tools/Models/Business/ReferenceEvent.cs index 62dfa20533bf..b2af984a63bc 100644 --- a/src/Core/Tools/Models/Business/ReferenceEvent.cs +++ b/src/Core/Tools/Models/Business/ReferenceEvent.cs @@ -70,4 +70,6 @@ public ReferenceEvent(ReferenceEventType type, IReferenceable source, ICurrentCo public string ClientId { get; set; } public Version? ClientVersion { get; set; } + public int? SmSeats { get; set; } + public int? ServiceAccounts { get; set; } } diff --git a/src/Core/Utilities/PasswordManagerPlanStore.cs b/src/Core/Utilities/PasswordManagerPlanStore.cs new file mode 100644 index 000000000000..1f9400eba61b --- /dev/null +++ b/src/Core/Utilities/PasswordManagerPlanStore.cs @@ -0,0 +1,398 @@ +using Bit.Core.Enums; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.Utilities; + +public static class PasswordManagerPlanStore +{ + public static IEnumerable CreatePlan() + { + return new List + { + new Plan + { + Type = PlanType.Free, + Product = ProductType.Free, + BitwardenProduct = BitwardenProductType.PasswordManager, + Name = "Free", + NameLocalizationKey = "planNameFree", + DescriptionLocalizationKey = "planDescFree", + BaseSeats = 2, + MaxCollections = 2, + MaxUsers = 2, + + UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to + DisplaySortOrder = -1, + + AllowSeatAutoscale = false, + }, + new Plan + { + Type = PlanType.FamiliesAnnually2019, + Product = ProductType.Families, + BitwardenProduct = BitwardenProductType.PasswordManager, + Name = "Families 2019", + IsAnnual = true, + NameLocalizationKey = "planNameFamilies", + DescriptionLocalizationKey = "planDescFamilies", + BaseSeats = 5, + BaseStorageGb = 1, + MaxUsers = 5, + + HasAdditionalStorageOption = true, + HasPremiumAccessOption = true, + TrialPeriodDays = 7, + + HasSelfHost = true, + HasTotp = true, + + UpgradeSortOrder = 1, + DisplaySortOrder = 1, + LegacyYear = 2020, + + StripePlanId = "personal-org-annually", + StripeStoragePlanId = "storage-gb-annually", + StripePremiumAccessPlanId = "personal-org-premium-access-annually", + BasePrice = 12, + AdditionalStoragePricePerGb = 4, + PremiumAccessOptionPrice = 40, + + AllowSeatAutoscale = false, + }, + new Plan + { + Type = PlanType.TeamsAnnually2019, + Product = ProductType.Teams, + BitwardenProduct = BitwardenProductType.PasswordManager, + Name = "Teams (Annually) 2019", + IsAnnual = true, + NameLocalizationKey = "planNameTeams", + DescriptionLocalizationKey = "planDescTeams", + CanBeUsedByBusiness = true, + BaseSeats = 5, + BaseStorageGb = 1, + + HasAdditionalSeatsOption = true, + HasAdditionalStorageOption = true, + TrialPeriodDays = 7, + + HasTotp = true, + + UpgradeSortOrder = 2, + DisplaySortOrder = 2, + LegacyYear = 2020, + + StripePlanId = "teams-org-annually", + StripeSeatPlanId = "teams-org-seat-annually", + StripeStoragePlanId = "storage-gb-annually", + BasePrice = 60, + SeatPrice = 24, + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.TeamsMonthly2019, + Product = ProductType.Teams, + BitwardenProduct = BitwardenProductType.PasswordManager, + Name = "Teams (Monthly) 2019", + NameLocalizationKey = "planNameTeams", + DescriptionLocalizationKey = "planDescTeams", + CanBeUsedByBusiness = true, + BaseSeats = 5, + BaseStorageGb = 1, + + HasAdditionalSeatsOption = true, + HasAdditionalStorageOption = true, + TrialPeriodDays = 7, + + HasTotp = true, + + UpgradeSortOrder = 2, + DisplaySortOrder = 2, + LegacyYear = 2020, + + StripePlanId = "teams-org-monthly", + StripeSeatPlanId = "teams-org-seat-monthly", + StripeStoragePlanId = "storage-gb-monthly", + BasePrice = 8, + SeatPrice = 2.5M, + AdditionalStoragePricePerGb = 0.5M, + + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.EnterpriseAnnually2019, + Name = "Enterprise (Annually) 2019", + IsAnnual = true, + Product = ProductType.Enterprise, + BitwardenProduct = BitwardenProductType.PasswordManager, + NameLocalizationKey = "planNameEnterprise", + DescriptionLocalizationKey = "planDescEnterprise", + CanBeUsedByBusiness = true, + BaseSeats = 0, + BaseStorageGb = 1, + + HasAdditionalSeatsOption = true, + HasAdditionalStorageOption = true, + TrialPeriodDays = 7, + + HasPolicies = true, + HasSelfHost = true, + HasGroups = true, + HasDirectory = true, + HasEvents = true, + HasTotp = true, + Has2fa = true, + HasApi = true, + UsersGetPremium = true, + HasCustomPermissions = true, + + UpgradeSortOrder = 3, + DisplaySortOrder = 3, + LegacyYear = 2020, + + StripePlanId = null, + StripeSeatPlanId = "enterprise-org-seat-annually", + StripeStoragePlanId = "storage-gb-annually", + BasePrice = 0, + SeatPrice = 36, + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.EnterpriseMonthly2019, + Product = ProductType.Enterprise, + BitwardenProduct = BitwardenProductType.PasswordManager, + Name = "Enterprise (Monthly) 2019", + NameLocalizationKey = "planNameEnterprise", + DescriptionLocalizationKey = "planDescEnterprise", + CanBeUsedByBusiness = true, + BaseSeats = 0, + BaseStorageGb = 1, + + HasAdditionalSeatsOption = true, + HasAdditionalStorageOption = true, + TrialPeriodDays = 7, + + HasPolicies = true, + HasGroups = true, + HasDirectory = true, + HasEvents = true, + HasTotp = true, + Has2fa = true, + HasApi = true, + HasSelfHost = true, + UsersGetPremium = true, + HasCustomPermissions = true, + + UpgradeSortOrder = 3, + DisplaySortOrder = 3, + LegacyYear = 2020, + + StripePlanId = null, + StripeSeatPlanId = "enterprise-org-seat-monthly", + StripeStoragePlanId = "storage-gb-monthly", + BasePrice = 0, + SeatPrice = 4M, + AdditionalStoragePricePerGb = 0.5M, + + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.FamiliesAnnually, + Product = ProductType.Families, + BitwardenProduct = BitwardenProductType.PasswordManager, + Name = "Families", + IsAnnual = true, + NameLocalizationKey = "planNameFamilies", + DescriptionLocalizationKey = "planDescFamilies", + BaseSeats = 6, + BaseStorageGb = 1, + MaxUsers = 6, + + HasAdditionalStorageOption = true, + TrialPeriodDays = 7, + + HasSelfHost = true, + HasTotp = true, + UsersGetPremium = true, + + UpgradeSortOrder = 1, + DisplaySortOrder = 1, + + StripePlanId = "2020-families-org-annually", + StripeStoragePlanId = "storage-gb-annually", + BasePrice = 40, + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = false, + }, + new Plan + { + Type = PlanType.TeamsAnnually, + Product = ProductType.Teams, + BitwardenProduct = BitwardenProductType.PasswordManager, + Name = "Teams (Annually)", + IsAnnual = true, + NameLocalizationKey = "planNameTeams", + DescriptionLocalizationKey = "planDescTeams", + CanBeUsedByBusiness = true, + BaseStorageGb = 1, + BaseSeats = 0, + + HasAdditionalSeatsOption = true, + HasAdditionalStorageOption = true, + TrialPeriodDays = 7, + + Has2fa = true, + HasApi = true, + HasDirectory = true, + HasEvents = true, + HasGroups = true, + HasTotp = true, + UsersGetPremium = true, + + UpgradeSortOrder = 2, + DisplaySortOrder = 2, + + StripeSeatPlanId = "2020-teams-org-seat-annually", + StripeStoragePlanId = "storage-gb-annually", + SeatPrice = 36, + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.TeamsMonthly, + Product = ProductType.Teams, + BitwardenProduct = BitwardenProductType.PasswordManager, + Name = "Teams (Monthly)", + NameLocalizationKey = "planNameTeams", + DescriptionLocalizationKey = "planDescTeams", + CanBeUsedByBusiness = true, + BaseStorageGb = 1, + BaseSeats = 0, + + HasAdditionalSeatsOption = true, + HasAdditionalStorageOption = true, + TrialPeriodDays = 7, + + Has2fa = true, + HasApi = true, + HasDirectory = true, + HasEvents = true, + HasGroups = true, + HasTotp = true, + UsersGetPremium = true, + + UpgradeSortOrder = 2, + DisplaySortOrder = 2, + + StripeSeatPlanId = "2020-teams-org-seat-monthly", + StripeStoragePlanId = "storage-gb-monthly", + SeatPrice = 4, + AdditionalStoragePricePerGb = 0.5M, + + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.EnterpriseAnnually, + Name = "Enterprise (Annually)", + Product = ProductType.Enterprise, + BitwardenProduct = BitwardenProductType.PasswordManager, + IsAnnual = true, + NameLocalizationKey = "planNameEnterprise", + DescriptionLocalizationKey = "planDescEnterprise", + CanBeUsedByBusiness = true, + BaseSeats = 0, + BaseStorageGb = 1, + + HasAdditionalSeatsOption = true, + HasAdditionalStorageOption = true, + TrialPeriodDays = 7, + + HasPolicies = true, + HasSelfHost = true, + HasGroups = true, + HasDirectory = true, + HasEvents = true, + HasTotp = true, + Has2fa = true, + HasApi = true, + HasSso = true, + HasKeyConnector = true, + HasScim = true, + HasResetPassword = true, + UsersGetPremium = true, + HasCustomPermissions = true, + + UpgradeSortOrder = 3, + DisplaySortOrder = 3, + + StripeSeatPlanId = "2020-enterprise-org-seat-annually", + StripeStoragePlanId = "storage-gb-annually", + BasePrice = 0, + SeatPrice = 60, + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.EnterpriseMonthly, + Product = ProductType.Enterprise, + BitwardenProduct = BitwardenProductType.PasswordManager, + Name = "Enterprise (Monthly)", + NameLocalizationKey = "planNameEnterprise", + DescriptionLocalizationKey = "planDescEnterprise", + CanBeUsedByBusiness = true, + BaseSeats = 0, + BaseStorageGb = 1, + + HasAdditionalSeatsOption = true, + HasAdditionalStorageOption = true, + TrialPeriodDays = 7, + + HasPolicies = true, + HasGroups = true, + HasDirectory = true, + HasEvents = true, + HasTotp = true, + Has2fa = true, + HasApi = true, + HasSelfHost = true, + HasSso = true, + HasKeyConnector = true, + HasScim = true, + HasResetPassword = true, + UsersGetPremium = true, + HasCustomPermissions = true, + + UpgradeSortOrder = 3, + DisplaySortOrder = 3, + + StripeSeatPlanId = "2020-enterprise-seat-monthly", + StripeStoragePlanId = "storage-gb-monthly", + BasePrice = 0, + SeatPrice = 6, + AdditionalStoragePricePerGb = 0.5M, + + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.Custom, + + AllowSeatAutoscale = true, + }, + }; + } +} diff --git a/src/Core/Utilities/SecretsManagerPlanStore.cs b/src/Core/Utilities/SecretsManagerPlanStore.cs new file mode 100644 index 000000000000..233cd116fa8d --- /dev/null +++ b/src/Core/Utilities/SecretsManagerPlanStore.cs @@ -0,0 +1,181 @@ +using Bit.Core.Enums; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.Utilities; + +public static class SecretsManagerPlanStore +{ + public static IEnumerable CreatePlan() + { + return new List + { + new Plan + { + Type = PlanType.EnterpriseMonthly, + Product = ProductType.Enterprise, + BitwardenProduct = BitwardenProductType.SecretsManager, + Name = "Enterprise (Monthly)", + NameLocalizationKey = "planNameEnterprise", + DescriptionLocalizationKey = "planDescEnterprise", + CanBeUsedByBusiness = true, + BaseSeats = 0, + BaseServiceAccount = 200, + HasAdditionalSeatsOption = true, + HasAdditionalServiceAccountOption = true, + TrialPeriodDays = 7, + HasPolicies = true, + HasGroups = true, + HasDirectory = true, + HasEvents = true, + HasTotp = true, + Has2fa = true, + HasApi = true, + HasSelfHost = true, + HasSso = true, + HasKeyConnector = true, + HasScim = true, + HasResetPassword = true, + UsersGetPremium = true, + HasCustomPermissions = true, + UpgradeSortOrder = 3, + DisplaySortOrder = 3, + StripeSeatPlanId = "sm-enterprise-seat-monthly", + StripeStoragePlanId = "service-account-monthly", + BasePrice = 0, + SeatPrice = 12, + AdditionalPricePerServiceAccount = 0.5M, + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.EnterpriseAnnually, + Name = "Enterprise (Annually)", + Product = ProductType.Enterprise, + BitwardenProduct = BitwardenProductType.SecretsManager, + IsAnnual = true, + NameLocalizationKey = "planNameEnterprise", + DescriptionLocalizationKey = "planDescEnterprise", + CanBeUsedByBusiness = true, + BaseSeats = 0, + BaseServiceAccount = 200, + HasAdditionalSeatsOption = true, + HasAdditionalServiceAccountOption = true, + TrialPeriodDays = 7, + HasPolicies = true, + HasSelfHost = true, + HasGroups = true, + HasDirectory = true, + HasEvents = true, + HasTotp = true, + Has2fa = true, + HasApi = true, + HasSso = true, + HasKeyConnector = true, + HasScim = true, + HasResetPassword = true, + UsersGetPremium = true, + HasCustomPermissions = true, + UpgradeSortOrder = 3, + DisplaySortOrder = 3, + StripeSeatPlanId = "sm-enterprise-seat-annually", + StripeStoragePlanId = "service-account-annually", + BasePrice = 0, + SeatPrice = 10, + AdditionalPricePerServiceAccount = 0.5M, + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.TeamsMonthly, + Name = "Teams (Monthly)", + Product = ProductType.Teams, + BitwardenProduct = BitwardenProductType.SecretsManager, + NameLocalizationKey = "planNameTeams", + DescriptionLocalizationKey = "planDescTeams", + CanBeUsedByBusiness = true, + BaseSeats = 0, + BaseServiceAccount = 50, + HasAdditionalSeatsOption = true, + HasAdditionalServiceAccountOption = true, + TrialPeriodDays = 7, + HasPolicies = true, + HasSelfHost = true, + HasGroups = true, + HasDirectory = true, + HasEvents = true, + HasTotp = true, + Has2fa = true, + HasApi = true, + HasSso = true, + HasKeyConnector = true, + HasScim = true, + HasResetPassword = true, + UsersGetPremium = true, + HasCustomPermissions = true, + UpgradeSortOrder = 3, + DisplaySortOrder = 3, + StripeSeatPlanId = "sm-teams-seat-monthly", + StripeStoragePlanId = "service-account-monthly", + BasePrice = 0, + SeatPrice = 6, + AdditionalPricePerServiceAccount = 0.5M, + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.TeamsAnnually, + Name = "Teams (Annually)", + Product = ProductType.Teams, + BitwardenProduct = BitwardenProductType.SecretsManager, + IsAnnual = true, + NameLocalizationKey = "planNameTeams", + DescriptionLocalizationKey = "planDescTeams", + CanBeUsedByBusiness = true, + BaseSeats = 0, + BaseServiceAccount = 50, + HasAdditionalSeatsOption = true, + HasAdditionalServiceAccountOption = true, + TrialPeriodDays = 7, + HasPolicies = true, + HasSelfHost = true, + HasGroups = true, + HasDirectory = true, + HasEvents = true, + HasTotp = true, + Has2fa = true, + HasApi = true, + HasSso = true, + HasKeyConnector = true, + HasScim = true, + HasResetPassword = true, + UsersGetPremium = true, + HasCustomPermissions = true, + UpgradeSortOrder = 3, + DisplaySortOrder = 3, + StripeSeatPlanId = "sm-teams-seat-annually", + StripeStoragePlanId = "service-account-annually", + BasePrice = 0, + SeatPrice = 4, + AdditionalPricePerServiceAccount = 0.5M, + AllowSeatAutoscale = true, + }, + new Plan + { + Type = PlanType.Free, + Product = ProductType.Free, + BitwardenProduct = BitwardenProductType.SecretsManager, + Name = "Free", + NameLocalizationKey = "planNameFree", + DescriptionLocalizationKey = "planDescFree", + BaseSeats = 2, + BaseServiceAccount = 3, + MaxCollections = 2, + MaxUsers = 2, + MaxServiceAccount = 3, + UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to + DisplaySortOrder = -1, + AllowSeatAutoscale = false, + } + }; + } +} diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index 4b257a9cc6b6..c5183296a9e6 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -106,388 +106,19 @@ static StaticStore() #region Plans - Plans = new List - { - new Plan - { - Type = PlanType.Free, - Product = ProductType.Free, - Name = "Free", - NameLocalizationKey = "planNameFree", - DescriptionLocalizationKey = "planDescFree", - BaseSeats = 2, - MaxCollections = 2, - MaxUsers = 2, - - UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to - DisplaySortOrder = -1, - - AllowSeatAutoscale = false, - }, - new Plan - { - Type = PlanType.FamiliesAnnually2019, - Product = ProductType.Families, - Name = "Families 2019", - IsAnnual = true, - NameLocalizationKey = "planNameFamilies", - DescriptionLocalizationKey = "planDescFamilies", - BaseSeats = 5, - BaseStorageGb = 1, - MaxUsers = 5, - - HasAdditionalStorageOption = true, - HasPremiumAccessOption = true, - TrialPeriodDays = 7, - - HasSelfHost = true, - HasTotp = true, - - UpgradeSortOrder = 1, - DisplaySortOrder = 1, - LegacyYear = 2020, - - StripePlanId = "personal-org-annually", - StripeStoragePlanId = "storage-gb-annually", - StripePremiumAccessPlanId = "personal-org-premium-access-annually", - BasePrice = 12, - AdditionalStoragePricePerGb = 4, - PremiumAccessOptionPrice = 40, - - AllowSeatAutoscale = false, - }, - new Plan - { - Type = PlanType.TeamsAnnually2019, - Product = ProductType.Teams, - Name = "Teams (Annually) 2019", - IsAnnual = true, - NameLocalizationKey = "planNameTeams", - DescriptionLocalizationKey = "planDescTeams", - CanBeUsedByBusiness = true, - BaseSeats = 5, - BaseStorageGb = 1, - - HasAdditionalSeatsOption = true, - HasAdditionalStorageOption = true, - TrialPeriodDays = 7, - - HasTotp = true, - - UpgradeSortOrder = 2, - DisplaySortOrder = 2, - LegacyYear = 2020, - - StripePlanId = "teams-org-annually", - StripeSeatPlanId = "teams-org-seat-annually", - StripeStoragePlanId = "storage-gb-annually", - BasePrice = 60, - SeatPrice = 24, - AdditionalStoragePricePerGb = 4, - - AllowSeatAutoscale = true, - }, - new Plan - { - Type = PlanType.TeamsMonthly2019, - Product = ProductType.Teams, - Name = "Teams (Monthly) 2019", - NameLocalizationKey = "planNameTeams", - DescriptionLocalizationKey = "planDescTeams", - CanBeUsedByBusiness = true, - BaseSeats = 5, - BaseStorageGb = 1, + PasswordManagerPlans = PasswordManagerPlanStore.CreatePlan(); + SecretManagerPlans = SecretsManagerPlanStore.CreatePlan(); - HasAdditionalSeatsOption = true, - HasAdditionalStorageOption = true, - TrialPeriodDays = 7, - - HasTotp = true, - - UpgradeSortOrder = 2, - DisplaySortOrder = 2, - LegacyYear = 2020, - - StripePlanId = "teams-org-monthly", - StripeSeatPlanId = "teams-org-seat-monthly", - StripeStoragePlanId = "storage-gb-monthly", - BasePrice = 8, - SeatPrice = 2.5M, - AdditionalStoragePricePerGb = 0.5M, - - AllowSeatAutoscale = true, - }, - new Plan - { - Type = PlanType.EnterpriseAnnually2019, - Name = "Enterprise (Annually) 2019", - IsAnnual = true, - Product = ProductType.Enterprise, - NameLocalizationKey = "planNameEnterprise", - DescriptionLocalizationKey = "planDescEnterprise", - CanBeUsedByBusiness = true, - BaseSeats = 0, - BaseStorageGb = 1, - - HasAdditionalSeatsOption = true, - HasAdditionalStorageOption = true, - TrialPeriodDays = 7, - - HasPolicies = true, - HasSelfHost = true, - HasGroups = true, - HasDirectory = true, - HasEvents = true, - HasTotp = true, - Has2fa = true, - HasApi = true, - UsersGetPremium = true, - HasCustomPermissions = true, - - UpgradeSortOrder = 3, - DisplaySortOrder = 3, - LegacyYear = 2020, - - StripePlanId = null, - StripeSeatPlanId = "enterprise-org-seat-annually", - StripeStoragePlanId = "storage-gb-annually", - BasePrice = 0, - SeatPrice = 36, - AdditionalStoragePricePerGb = 4, - - AllowSeatAutoscale = true, - }, - new Plan - { - Type = PlanType.EnterpriseMonthly2019, - Product = ProductType.Enterprise, - Name = "Enterprise (Monthly) 2019", - NameLocalizationKey = "planNameEnterprise", - DescriptionLocalizationKey = "planDescEnterprise", - CanBeUsedByBusiness = true, - BaseSeats = 0, - BaseStorageGb = 1, - - HasAdditionalSeatsOption = true, - HasAdditionalStorageOption = true, - TrialPeriodDays = 7, - - HasPolicies = true, - HasGroups = true, - HasDirectory = true, - HasEvents = true, - HasTotp = true, - Has2fa = true, - HasApi = true, - HasSelfHost = true, - UsersGetPremium = true, - HasCustomPermissions = true, - - UpgradeSortOrder = 3, - DisplaySortOrder = 3, - LegacyYear = 2020, - - StripePlanId = null, - StripeSeatPlanId = "enterprise-org-seat-monthly", - StripeStoragePlanId = "storage-gb-monthly", - BasePrice = 0, - SeatPrice = 4M, - AdditionalStoragePricePerGb = 0.5M, - - AllowSeatAutoscale = true, - }, - new Plan - { - Type = PlanType.FamiliesAnnually, - Product = ProductType.Families, - Name = "Families", - IsAnnual = true, - NameLocalizationKey = "planNameFamilies", - DescriptionLocalizationKey = "planDescFamilies", - BaseSeats = 6, - BaseStorageGb = 1, - MaxUsers = 6, + Plans = PasswordManagerPlans.Concat(SecretManagerPlans); - HasAdditionalStorageOption = true, - TrialPeriodDays = 7, - - HasSelfHost = true, - HasTotp = true, - UsersGetPremium = true, - - UpgradeSortOrder = 1, - DisplaySortOrder = 1, - - StripePlanId = "2020-families-org-annually", - StripeStoragePlanId = "storage-gb-annually", - BasePrice = 40, - AdditionalStoragePricePerGb = 4, - - AllowSeatAutoscale = false, - }, - new Plan - { - Type = PlanType.TeamsAnnually, - Product = ProductType.Teams, - Name = "Teams (Annually)", - IsAnnual = true, - NameLocalizationKey = "planNameTeams", - DescriptionLocalizationKey = "planDescTeams", - CanBeUsedByBusiness = true, - BaseStorageGb = 1, - BaseSeats = 0, - - HasAdditionalSeatsOption = true, - HasAdditionalStorageOption = true, - TrialPeriodDays = 7, - - Has2fa = true, - HasApi = true, - HasDirectory = true, - HasEvents = true, - HasGroups = true, - HasTotp = true, - UsersGetPremium = true, - - UpgradeSortOrder = 2, - DisplaySortOrder = 2, - - StripeSeatPlanId = "2020-teams-org-seat-annually", - StripeStoragePlanId = "storage-gb-annually", - SeatPrice = 36, - AdditionalStoragePricePerGb = 4, - - AllowSeatAutoscale = true, - }, - new Plan - { - Type = PlanType.TeamsMonthly, - Product = ProductType.Teams, - Name = "Teams (Monthly)", - NameLocalizationKey = "planNameTeams", - DescriptionLocalizationKey = "planDescTeams", - CanBeUsedByBusiness = true, - BaseStorageGb = 1, - BaseSeats = 0, - - HasAdditionalSeatsOption = true, - HasAdditionalStorageOption = true, - TrialPeriodDays = 7, - - Has2fa = true, - HasApi = true, - HasDirectory = true, - HasEvents = true, - HasGroups = true, - HasTotp = true, - UsersGetPremium = true, - - UpgradeSortOrder = 2, - DisplaySortOrder = 2, - - StripeSeatPlanId = "2020-teams-org-seat-monthly", - StripeStoragePlanId = "storage-gb-monthly", - SeatPrice = 4, - AdditionalStoragePricePerGb = 0.5M, - - AllowSeatAutoscale = true, - }, - new Plan - { - Type = PlanType.EnterpriseAnnually, - Name = "Enterprise (Annually)", - Product = ProductType.Enterprise, - IsAnnual = true, - NameLocalizationKey = "planNameEnterprise", - DescriptionLocalizationKey = "planDescEnterprise", - CanBeUsedByBusiness = true, - BaseSeats = 0, - BaseStorageGb = 1, - - HasAdditionalSeatsOption = true, - HasAdditionalStorageOption = true, - TrialPeriodDays = 7, - - HasPolicies = true, - HasSelfHost = true, - HasGroups = true, - HasDirectory = true, - HasEvents = true, - HasTotp = true, - Has2fa = true, - HasApi = true, - HasSso = true, - HasKeyConnector = true, - HasScim = true, - HasResetPassword = true, - UsersGetPremium = true, - HasCustomPermissions = true, - - UpgradeSortOrder = 3, - DisplaySortOrder = 3, - - StripeSeatPlanId = "2020-enterprise-org-seat-annually", - StripeStoragePlanId = "storage-gb-annually", - BasePrice = 0, - SeatPrice = 60, - AdditionalStoragePricePerGb = 4, - - AllowSeatAutoscale = true, - }, - new Plan - { - Type = PlanType.EnterpriseMonthly, - Product = ProductType.Enterprise, - Name = "Enterprise (Monthly)", - NameLocalizationKey = "planNameEnterprise", - DescriptionLocalizationKey = "planDescEnterprise", - CanBeUsedByBusiness = true, - BaseSeats = 0, - BaseStorageGb = 1, - - HasAdditionalSeatsOption = true, - HasAdditionalStorageOption = true, - TrialPeriodDays = 7, - - HasPolicies = true, - HasGroups = true, - HasDirectory = true, - HasEvents = true, - HasTotp = true, - Has2fa = true, - HasApi = true, - HasSelfHost = true, - HasSso = true, - HasKeyConnector = true, - HasScim = true, - HasResetPassword = true, - UsersGetPremium = true, - HasCustomPermissions = true, - - UpgradeSortOrder = 3, - DisplaySortOrder = 3, - - StripeSeatPlanId = "2020-enterprise-seat-monthly", - StripeStoragePlanId = "storage-gb-monthly", - BasePrice = 0, - SeatPrice = 6, - AdditionalStoragePricePerGb = 0.5M, - - AllowSeatAutoscale = true, - }, - new Plan - { - Type = PlanType.Custom, - - AllowSeatAutoscale = true, - }, - }; #endregion } public static IDictionary> GlobalDomains { get; set; } public static IEnumerable Plans { get; set; } + public static IEnumerable SecretManagerPlans { get; set; } + public static IEnumerable PasswordManagerPlans { get; set; } public static IEnumerable SponsoredPlans { get; set; } = new[] { new SponsoredPlan @@ -497,11 +128,15 @@ static StaticStore() SponsoringProductType = ProductType.Enterprise, StripePlanId = "2021-family-for-enterprise-annually", UsersCanSponsor = (OrganizationUserOrganizationDetails org) => - GetPlan(org.PlanType).Product == ProductType.Enterprise, + GetPasswordManagerPlan(org.PlanType).Product == ProductType.Enterprise, } }; - public static Plan GetPlan(PlanType planType) => - Plans.FirstOrDefault(p => p.Type == planType); + public static Plan GetPasswordManagerPlan(PlanType planType) => + PasswordManagerPlans.SingleOrDefault(p => p.Type == planType); + + public static Plan GetSecretsManagerPlan(PlanType planType) => + SecretManagerPlans.SingleOrDefault(p => p.Type == planType); + public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) => SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType); } diff --git a/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs index ca246949089d..e14cd9e357b0 100644 --- a/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs @@ -99,6 +99,32 @@ public async Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizati } } + public async Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var result = await connection.ExecuteScalarAsync( + "[dbo].[OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task GetOccupiedServiceAccountCountByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var result = await connection.ExecuteScalarAsync( + "[dbo].[ServiceAccount_ReadCountByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + public async Task> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, bool onlyRegisteredUsers) { diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs index 282304720fa8..0966b4dd61c7 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs @@ -621,4 +621,21 @@ on p.OrganizationId equals ou.OrganizationId return await query.ToListAsync(); } } + + public async Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId) + { + var query = new OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery(organizationId); + return await GetCountFromQuery(query); + } + + public async Task GetOccupiedServiceAccountCountByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + return await dbContext.ServiceAccount + .Where(ou => ou.OrganizationId == organizationId) + .CountAsync(); + } + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery.cs new file mode 100644 index 000000000000..0f21a80ba64f --- /dev/null +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery.cs @@ -0,0 +1,22 @@ +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery : IQuery +{ + private readonly Guid _organizationId; + + public OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery(Guid organizationId) + { + _organizationId = organizationId; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var query = from ou in dbContext.OrganizationUsers + where ou.OrganizationId == _organizationId && ou.Status >= OrganizationUserStatusType.Invited && ou.AccessSecretsManager == true + select ou; + return query; + } +} diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId.sql new file mode 100644 index 000000000000..3c3792effe59 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(1) + FROM + [dbo].[OrganizationUserView] + WHERE + OrganizationId = @OrganizationId + AND Status >= 0 --Invited + AND AccessSecretsManager = 1 +END +GO diff --git a/src/Sql/dbo/Stored Procedures/ServiceAccount_ReadCountByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/ServiceAccount_ReadCountByOrganizationId.sql new file mode 100644 index 000000000000..e904df1b09a5 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/ServiceAccount_ReadCountByOrganizationId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[ServiceAccount_ReadCountByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(1) + FROM + [dbo].[ServiceAccount] + WHERE + OrganizationId = @OrganizationId +END \ No newline at end of file diff --git a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs index e58add5ef06f..99bc1df636cf 100644 --- a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -20,11 +20,11 @@ namespace Bit.Api.Test.Controllers; public class OrganizationSponsorshipsControllerTests { public static IEnumerable EnterprisePlanTypes => - Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product == ProductType.Enterprise).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPasswordManagerPlan(p).Product == ProductType.Enterprise).Select(p => new object[] { p }); public static IEnumerable NonEnterprisePlanTypes => - Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product != ProductType.Enterprise).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPasswordManagerPlan(p).Product != ProductType.Enterprise).Select(p => new object[] { p }); public static IEnumerable NonFamiliesPlanTypes => - Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product != ProductType.Families).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPasswordManagerPlan(p).Product != ProductType.Families).Select(p => new object[] { p }); public static IEnumerable NonConfirmedOrganizationUsersStatuses => Enum.GetValues() diff --git a/test/Api.Test/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Controllers/OrganizationsControllerTests.cs index 069b4bb27c70..86fecb5d145a 100644 --- a/test/Api.Test/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationsControllerTests.cs @@ -11,6 +11,8 @@ using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; +using Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -41,6 +43,8 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IFeatureService _featureService; private readonly ILicensingService _licensingService; + private readonly IOrganizationSignUpCommand _organizationSignUpCommand; + private readonly IOrganizationUpgradePlanCommand _organizationUpgradePlanCommand; private readonly OrganizationsController _sut; @@ -65,12 +69,15 @@ public OrganizationsControllerTests() _updateOrganizationLicenseCommand = Substitute.For(); _featureService = Substitute.For(); _licensingService = Substitute.For(); + _organizationSignUpCommand = Substitute.For(); + _organizationUpgradePlanCommand = Substitute.For(); _sut = new OrganizationsController(_organizationRepository, _organizationUserRepository, _policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext, _ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand, _createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand, - _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService); + _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService, + _organizationSignUpCommand, _organizationUpgradePlanCommand); } public void Dispose() diff --git a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs index d27a41ece670..0e1629192622 100644 --- a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs @@ -305,7 +305,7 @@ public async Task Get_ProviderPlanTypeProperlyPopulated(User user, if (matchedProviderUserOrgDetails != null) { - var providerOrgProductType = StaticStore.GetPlan(matchedProviderUserOrgDetails.PlanType).Product; + var providerOrgProductType = StaticStore.GetPasswordManagerPlan(matchedProviderUserOrgDetails.PlanType).Product; Assert.Equal(providerOrgProductType, profProviderOrg.PlanProductType); } } diff --git a/test/Core.Test/AutoFixture/OrganizationFixtures.cs b/test/Core.Test/AutoFixture/OrganizationFixtures.cs index 7d0044c36cee..ee0c13dd202f 100644 --- a/test/Core.Test/AutoFixture/OrganizationFixtures.cs +++ b/test/Core.Test/AutoFixture/OrganizationFixtures.cs @@ -65,7 +65,7 @@ internal class PaidOrganization : ICustomization public PlanType CheckedPlanType { get; set; } public void Customize(IFixture fixture) { - var validUpgradePlans = StaticStore.Plans.Where(p => p.Type != PlanType.Free && p.LegacyYear == null).OrderBy(p => p.UpgradeSortOrder).Select(p => p.Type).ToList(); + var validUpgradePlans = StaticStore.PasswordManagerPlans.Where(p => p.Type != PlanType.Free && p.LegacyYear == null).OrderBy(p => p.UpgradeSortOrder).Select(p => p.Type).ToList(); var lowestActivePaidPlan = validUpgradePlans.First(); CheckedPlanType = CheckedPlanType.Equals(PlanType.Free) ? lowestActivePaidPlan : CheckedPlanType; validUpgradePlans.Remove(lowestActivePaidPlan); @@ -93,7 +93,7 @@ public void Customize(IFixture fixture) .With(o => o.PlanType, PlanType.Free)); var plansToIgnore = new List { PlanType.Free, PlanType.Custom }; - var selectedPlan = StaticStore.Plans.Last(p => !plansToIgnore.Contains(p.Type) && !p.Disabled); + var selectedPlan = StaticStore.PasswordManagerPlans.Last(p => !plansToIgnore.Contains(p.Type) && !p.Disabled); fixture.Customize(composer => composer .With(ou => ou.Plan, selectedPlan.Type) diff --git a/test/Core.Test/Models/Data/SelfHostedOrganizationDetailsTests.cs b/test/Core.Test/Models/Data/SelfHostedOrganizationDetailsTests.cs index c0ab7b9c4014..b69b7e51bef0 100644 --- a/test/Core.Test/Models/Data/SelfHostedOrganizationDetailsTests.cs +++ b/test/Core.Test/Models/Data/SelfHostedOrganizationDetailsTests.cs @@ -17,7 +17,7 @@ public class SelfHostedOrganizationDetailsTests [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_Success(List orgUsers, + public Task ValidateForOrganization_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -26,12 +26,13 @@ public async Task ValidateForOrganization_Success(List orgUser Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_OccupiedSeatCount_ExceedsLicense_Fail(List orgUsers, + public Task ValidateForOrganization_OccupiedSeatCount_ExceedsLicense_Fail(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -41,12 +42,13 @@ public async Task ValidateForOrganization_OccupiedSeatCount_ExceedsLicense_Fail( Assert.False(result); Assert.Contains("Remove some users", exception); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_MaxCollections_ExceedsLicense_Fail(List orgUsers, + public Task ValidateForOrganization_MaxCollections_ExceedsLicense_Fail(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -56,12 +58,13 @@ public async Task ValidateForOrganization_MaxCollections_ExceedsLicense_Fail(Lis Assert.False(result); Assert.Contains("Remove some collections", exception); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_Groups_NotAllowedByLicense_Fail(List orgUsers, + public Task ValidateForOrganization_Groups_NotAllowedByLicense_Fail(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -71,12 +74,13 @@ public async Task ValidateForOrganization_Groups_NotAllowedByLicense_Fail(List orgUsers, + public Task ValidateForOrganization_Policies_NotAllowedByLicense_Fail(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -86,12 +90,13 @@ public async Task ValidateForOrganization_Policies_NotAllowedByLicense_Fail(List Assert.False(result); Assert.Contains("Your new license does not allow for the use of policies", exception); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_DisabledPolicies_NotAllowedByLicense_Success(List orgUsers, + public Task ValidateForOrganization_DisabledPolicies_NotAllowedByLicense_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -102,12 +107,13 @@ public async Task ValidateForOrganization_DisabledPolicies_NotAllowedByLicense_S Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_Sso_NotAllowedByLicense_Fail(List orgUsers, + public Task ValidateForOrganization_Sso_NotAllowedByLicense_Fail(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -117,12 +123,13 @@ public async Task ValidateForOrganization_Sso_NotAllowedByLicense_Fail(List orgUsers, + public Task ValidateForOrganization_DisabledSso_NotAllowedByLicense_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -133,12 +140,13 @@ public async Task ValidateForOrganization_DisabledSso_NotAllowedByLicense_Succes Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_NoSso_NotAllowedByLicense_Success(List orgUsers, + public Task ValidateForOrganization_NoSso_NotAllowedByLicense_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -149,12 +157,13 @@ public async Task ValidateForOrganization_NoSso_NotAllowedByLicense_Success(List Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_KeyConnector_NotAllowedByLicense_Fail(List orgUsers, + public Task ValidateForOrganization_KeyConnector_NotAllowedByLicense_Fail(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -164,12 +173,13 @@ public async Task ValidateForOrganization_KeyConnector_NotAllowedByLicense_Fail( Assert.False(result); Assert.Contains("Your new license does not allow for the use of Key Connector", exception); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_DisabledKeyConnector_NotAllowedByLicense_Success(List orgUsers, + public Task ValidateForOrganization_DisabledKeyConnector_NotAllowedByLicense_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -180,12 +190,13 @@ public async Task ValidateForOrganization_DisabledKeyConnector_NotAllowedByLicen Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_NoSsoKeyConnector_NotAllowedByLicense_Success(List orgUsers, + public Task ValidateForOrganization_NoSsoKeyConnector_NotAllowedByLicense_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -196,12 +207,13 @@ public async Task ValidateForOrganization_NoSsoKeyConnector_NotAllowedByLicense_ Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_Scim_NotAllowedByLicense_Fail(List orgUsers, + public Task ValidateForOrganization_Scim_NotAllowedByLicense_Fail(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -211,12 +223,13 @@ public async Task ValidateForOrganization_Scim_NotAllowedByLicense_Fail(List orgUsers, + public Task ValidateForOrganization_DisabledScim_NotAllowedByLicense_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -228,12 +241,13 @@ public async Task ValidateForOrganization_DisabledScim_NotAllowedByLicense_Succe Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_NoScimConfig_NotAllowedByLicense_Success(List orgUsers, + public Task ValidateForOrganization_NoScimConfig_NotAllowedByLicense_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -244,12 +258,13 @@ public async Task ValidateForOrganization_NoScimConfig_NotAllowedByLicense_Succe Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_CustomPermissions_NotAllowedByLicense_Fail(List orgUsers, + public Task ValidateForOrganization_CustomPermissions_NotAllowedByLicense_Fail(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -259,12 +274,13 @@ public async Task ValidateForOrganization_CustomPermissions_NotAllowedByLicense_ Assert.False(result); Assert.Contains("Your new plan does not allow the Custom Permissions feature", exception); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_NoCustomPermissions_NotAllowedByLicense_Success(List orgUsers, + public Task ValidateForOrganization_NoCustomPermissions_NotAllowedByLicense_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -275,12 +291,13 @@ public async Task ValidateForOrganization_NoCustomPermissions_NotAllowedByLicens Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_ResetPassword_NotAllowedByLicense_Fail(List orgUsers, + public Task ValidateForOrganization_ResetPassword_NotAllowedByLicense_Fail(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -290,12 +307,13 @@ public async Task ValidateForOrganization_ResetPassword_NotAllowedByLicense_Fail Assert.False(result); Assert.Contains("Your new license does not allow the Password Reset feature", exception); + return Task.CompletedTask; } [Theory] [BitAutoData] [OrganizationLicenseCustomize] - public async Task ValidateForOrganization_DisabledResetPassword_NotAllowedByLicense_Success(List orgUsers, + public Task ValidateForOrganization_DisabledResetPassword_NotAllowedByLicense_Success(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); @@ -306,6 +324,7 @@ public async Task ValidateForOrganization_DisabledResetPassword_NotAllowedByLice Assert.True(result); Assert.True(string.IsNullOrEmpty(exception)); + return Task.CompletedTask; } private (SelfHostedOrganizationDetails organization, OrganizationLicense license) GetOrganizationAndLicense(List orgUsers, diff --git a/test/Core.Test/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradePlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradePlanCommandTests.cs new file mode 100644 index 000000000000..f2411400df52 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradePlanCommandTests.cs @@ -0,0 +1,156 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade.Interface; +using Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; +using Bit.Core.Services; +using Bit.Core.Tools.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationPlanUpgrade; + +[SutProviderCustomize] + +public class OrganizationUpgradePlanCommandTests +{ + + [Theory] + [BitAutoData] + public async Task UpgradePlanAsync_ExistingSubscription_ThrowsBadRequestException(SutProvider sutProvider) + { + var organizationId = Guid.NewGuid(); + var upgrade = new OrganizationUpgrade + { + Plan = PlanType.EnterpriseAnnually, + }; + var existingPlan = new Plan() + { + Type = PlanType.TeamsAnnually, + BitwardenProduct = BitwardenProductType.PasswordManager, + }; + + var newPlans = new List + { + new() + { + Type = PlanType.EnterpriseAnnually, + BaseSeats = 2, + BitwardenProduct = BitwardenProductType.PasswordManager, + + }, + new() + { + Type = PlanType.EnterpriseAnnually, + BaseSeats = 2, + BaseServiceAccount = 2, + BitwardenProduct = BitwardenProductType.SecretsManager, + + } + }; + + var organization = new Organization + { + Id = organizationId, + GatewayCustomerId = "gateway-customer-id", + GatewaySubscriptionId = "existing-subscription-id", + PlanType = PlanType.Free, + }; + + var organizationUpgradeQuery = sutProvider.GetDependency(); + organizationUpgradeQuery.GetOrgById(organizationId).Returns(organization); + organizationUpgradeQuery.ExistingPlan(PlanType.TeamsAnnually).Returns(existingPlan); + organizationUpgradeQuery.NewPlans(PlanType.EnterpriseAnnually).Returns(newPlans); + + var validateUpgradeCommand = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var referenceEventService = sutProvider.GetDependency(); + var currentContext = sutProvider.GetDependency(); + var paymentService = sutProvider.GetDependency(); + var organizationSignUpValidationStrategy = sutProvider.GetDependency(); + + var command = new OrganizationUpgradePlanCommand( + organizationUpgradeQuery, + validateUpgradeCommand, + organizationService, + referenceEventService, + currentContext, + paymentService, + organizationSignUpValidationStrategy + ); + + await Assert.ThrowsAsync(() => command.UpgradePlanAsync(organizationId, upgrade)); + } + + [Theory] + [BitAutoData] + public async Task UpgradePlanAsync_NoPaymentMethodAvailable_ThrowsBadRequestException(SutProvider sutProvider) + { + var organizationId = Guid.NewGuid(); + var upgrade = new OrganizationUpgrade + { + Plan = PlanType.EnterpriseAnnually, + }; + + var organization = new Organization + { + Id = organizationId, + GatewayCustomerId = null, + }; + + var existingPlan = new Plan() + { + Type = PlanType.TeamsAnnually, + BitwardenProduct = BitwardenProductType.PasswordManager, + }; + + var newPlans = new List + { + new() + { + Type = PlanType.EnterpriseAnnually, + BaseSeats = 2, + BitwardenProduct = BitwardenProductType.PasswordManager, + + }, + new() + { + Type = PlanType.EnterpriseAnnually, + BaseSeats = 2, + BaseServiceAccount = 2, + BitwardenProduct = BitwardenProductType.SecretsManager, + + } + }; + + var organizationUpgradeQuery = sutProvider.GetDependency(); + organizationUpgradeQuery.GetOrgById(organizationId).Returns(organization); + organizationUpgradeQuery.ExistingPlan(PlanType.TeamsAnnually).Returns(existingPlan); + organizationUpgradeQuery.NewPlans(PlanType.EnterpriseAnnually).Returns(newPlans); + + var validateUpgradeCommand = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var referenceEventService = sutProvider.GetDependency(); + var currentContext = sutProvider.GetDependency(); + var paymentService = sutProvider.GetDependency(); + var organizationSignUpValidationStrategy = sutProvider.GetDependency(); + + var command = new OrganizationUpgradePlanCommand( + organizationUpgradeQuery, + validateUpgradeCommand, + organizationService, + referenceEventService, + currentContext, + paymentService, + organizationSignUpValidationStrategy + ); + + await Assert.ThrowsAsync(() => command.UpgradePlanAsync(organizationId, upgrade)); + } +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradeQueryTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradeQueryTests.cs new file mode 100644 index 000000000000..914906f19be9 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationPlanUpgrade/OrganizationUpgradeQueryTests.cs @@ -0,0 +1,60 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationPlanUpgrade; + +[SutProviderCustomize] +public class OrganizationUpgradeQueryTests +{ + + [Theory] + [BitAutoData] + public void ExistingPlan_ShouldReturnMatchingPlan(SutProvider sutProvider) + { + const PlanType planType = PlanType.EnterpriseAnnually; + var expectedPlan = new Plan { Type = planType }; + var plans = new List { expectedPlan, new Plan { Type = PlanType.EnterpriseAnnually } }; + StaticStore.Plans = plans; + + var result = sutProvider.Sut.ExistingPlan(planType); + + Assert.Equal(expectedPlan, result); + } + + [Theory] + [BitAutoData] + public void NewPlans_ShouldReturnMatchingPlans(SutProvider sutProvider) + { + const PlanType planType = PlanType.EnterpriseAnnually; + var matchingPlan = new Plan { Type = planType, Disabled = false }; + var disabledPlan = new Plan { Type = planType, Disabled = true }; + var plans = new List { matchingPlan, disabledPlan }; + StaticStore.Plans = plans; + + var result = sutProvider.Sut.NewPlans(planType); + + Assert.Contains(matchingPlan, result); + Assert.DoesNotContain(disabledPlan, result); + } + + [Theory] + [BitAutoData] + public async Task GetOrgById_ShouldReturnOrganization(SutProvider sutProvider) + { + var orgId = Guid.NewGuid(); + var expectedOrganization = new Organization { Id = orgId }; + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(expectedOrganization); + + var result = await sutProvider.Sut.GetOrgById(orgId); + + Assert.Equal(expectedOrganization, result); + } +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationPlanUpgrade/ValidateUpgradeCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationPlanUpgrade/ValidateUpgradeCommandTests.cs new file mode 100644 index 000000000000..2cef5743e880 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationPlanUpgrade/ValidateUpgradeCommandTests.cs @@ -0,0 +1,163 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationPlanUpgrade; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationPlanUpgrade; + +[SutProviderCustomize] +public class ValidateUpgradeCommandTests +{ + [Theory] + [BitAutoData] + public void ValidatePlan_ExistingPlanIsNull_ThrowsBadRequestException(SutProvider sutProvider, Plan newPlan) + { + Assert.Throws(() => sutProvider.Sut.ValidatePlan(newPlan, null)); + } + + [Theory] + [BitAutoData] + public void ValidatePlan_NewPlanIsNull_ThrowsBadRequestException(SutProvider sutProvider, Plan existingPlan) + { + Assert.Throws(() => sutProvider.Sut.ValidatePlan(null, existingPlan)); + } + + [Theory] + [BitAutoData] + public void ValidatePlan_NewPlanIsDisabled_ThrowsBadRequestException(SutProvider sutProvider, Plan existingPlan, Plan newPlan) + { + newPlan = new Plan { Disabled = true }; + + Assert.Throws(() => sutProvider.Sut.ValidatePlan(newPlan, existingPlan)); + } + + [Theory] + [BitAutoData] + public void ValidatePlan_ExistingPlanTypeIsSameAsNewPlanType_ThrowsBadRequestException(SutProvider sutProvider, Plan existingPlan, Plan newPlan) + { + existingPlan = new Plan { Type = PlanType.EnterpriseAnnually }; + newPlan = new Plan { Type = PlanType.EnterpriseAnnually }; + + Assert.Throws(() => sutProvider.Sut.ValidatePlan(newPlan, existingPlan)); + } + + [Theory] + [BitAutoData] + public void + ValidatePlan_ExistingPlanUpgradeSortOrderIsGreaterThanOrEqualToNewPlanUpgradeSortOrder_ThrowsBadRequestException(SutProvider sutProvider, Plan existingPlan, Plan newPlan) + { + existingPlan = new Plan { UpgradeSortOrder = 2 }; + newPlan = new Plan { UpgradeSortOrder = 1 }; + + Assert.Throws(() => sutProvider.Sut.ValidatePlan(newPlan, existingPlan)); + } + + [Theory] + [BitAutoData] + public void ValidatePlan_ExistingPlanTypeIsNotFree_ThrowsBadRequestException(SutProvider sutProvider, Plan existingPlan, Plan newPlan) + { + existingPlan = new Plan { Type = PlanType.TeamsAnnually, UpgradeSortOrder = 2 }; + newPlan = new Plan { Type = PlanType.EnterpriseAnnually, UpgradeSortOrder = 3 }; + + Assert.Throws(() => sutProvider.Sut.ValidatePlan(newPlan, existingPlan)); + } + + [Theory] + [BitAutoData] + public async Task ValidateSeatsAsync_OrganizationSeatsIsGreaterThanNewPlanSeats_OccupiedSeatCountNotChecked( + SutProvider sutProvider, OrganizationUpgrade upgrade) + { + var organization = new Organization { Seats = 5 }; + var passwordManagerPlan = new Plan { BaseSeats = 5 }; + + await sutProvider.Sut.ValidateSeatsAsync(organization, passwordManagerPlan, upgrade); + + await sutProvider.GetDependency().DidNotReceive().GetOccupiedSeatCountByOrganizationIdAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateSmSeatsAsync_OrganizationSmSeatsIsGreaterThanNewPlanSeats_OccupiedSmSeatCountNotChecked( + SutProvider sutProvider) + { + var organization = new Organization { SmSeats = 5 }; + var newPlan = new Plan { BaseSeats = 5 }; + var upgrade = new OrganizationUpgrade(); + + await sutProvider.Sut.ValidateSmSeatsAsync(organization, newPlan, upgrade); + + await sutProvider.GetDependency().DidNotReceive().GetOccupiedSmSeatCountByOrganizationIdAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateCollectionsAsync_UpgradePlanAllowsAddingCollections_CollectionCountDoesNotExceedLimit( + SutProvider sutProvider) + { + var organization = new Organization { Id = Guid.NewGuid() }; + var newPlan = new Plan { MaxCollections = 5 }; + sutProvider.GetDependency().GetCountByOrganizationIdAsync(organization.Id).Returns(3); + + await sutProvider.Sut.ValidateCollectionsAsync(organization, newPlan); + + await sutProvider.GetDependency().Received(1).GetCountByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData] + public async Task ValidateGroupsAsync_NewPlanDoesNotAllowGroupsAndOrganizationHasGroups_ThrowsBadRequestException(SutProvider sutProvider) + { + var organization = new Organization { Id = Guid.NewGuid(), UseGroups = true }; + var newPlan = new Plan { HasGroups = false }; + var groups = new List { new Group() }; + + sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id).Returns(groups); + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ValidateGroupsAsync(organization, newPlan)); + await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData] + public async Task ValidatePoliciesAsync_NewPlanDoesNotAllowPoliciesAndOrganizationHasEnabledPolicies_ThrowsBadRequestException( + SutProvider sutProvider) + { + var organization = new Organization { Id = Guid.NewGuid(), UsePolicies = true }; + var newPlan = new Plan { HasPolicies = false }; + var policies = new List { new Policy { Enabled = true } }; + + sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id).Returns(policies); + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ValidatePoliciesAsync(organization, newPlan)); + + await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData] + public async Task ValidateSsoAsync_NewPlanDoesNotAllowSsoAndOrganizationHasEnabledSsoConfig_ThrowsBadRequestException(SutProvider sutProvider) + { + var organization = new Organization { Id = Guid.NewGuid(), UseSso = true }; + var newPlan = new Plan { HasSso = false }; + var ssoConfig = new SsoConfig { Enabled = true }; + + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ValidateSsoAsync(organization, newPlan)); + + await sutProvider.GetDependency().Received(1).GetByOrganizationIdAsync(organization.Id); + } + +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSignUp/OrganizationSignUpCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSignUp/OrganizationSignUpCommandTests.cs new file mode 100644 index 000000000000..088dd7b9d84f --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationSignUp/OrganizationSignUpCommandTests.cs @@ -0,0 +1,68 @@ +using AutoFixture; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSignUp; +using Bit.Core.OrganizationFeatures.OrganizationSignUp.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationSignUp; + +[SutProviderCustomize] +public class OrganizationSignUpCommandTests +{ + [Theory, BitAutoData] + public async Task SignUpAsync_WhenValidSignup_ReturnsOrganizationAndOrganizationUser( + SutProvider sutProvider, OrganizationSignup signup, + bool provider) + { + var fixture = new Fixture(); + var paymentService = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var referenceEventService = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + var organizationSignUpValidationStrategy = sutProvider.GetDependency(); + + var plans = fixture.Create>(); + + var passwordManagerPlan = fixture.Create(); + var organization = fixture.Create(); + var organizationUser = fixture.Create(); + + policyService.AnyPoliciesApplicableToUserAsync(signup.Owner.Id, PolicyType.SingleOrg).Returns(false); + organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id).Returns(0); + organizationService.SignUpAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), true) + .Returns(new Tuple(organization, organizationUser)); + + fixture.Inject(plans); + fixture.Inject(passwordManagerPlan); + fixture.Inject(organization); + fixture.Inject(organizationUser); + fixture.Inject(policyService); + fixture.Inject(organizationService); + fixture.Inject(paymentService); + fixture.Inject(referenceEventService); + fixture.Inject(organizationUserRepository); + fixture.Inject(organizationSignUpValidationStrategy); + + signup.AdditionalStorageGb = 0; + signup.AdditionalSeats = 0; + signup.UseSecretsManager = true; + signup.AdditionalServiceAccount = 0; + signup.AdditionalSmSeats = 0; + var result = await sutProvider.Sut.SignUpAsync(signup, provider); + + Assert.NotNull(result); + Assert.Equal(organization, result.Item1); + Assert.Equal(organizationUser, result.Item2); + } + +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSignUp/PasswordManagerSignUpValidationStrategyTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSignUp/PasswordManagerSignUpValidationStrategyTests.cs new file mode 100644 index 000000000000..44ba81ad9f80 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationSignUp/PasswordManagerSignUpValidationStrategyTests.cs @@ -0,0 +1,79 @@ +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSignUp; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationSignUp; + +[SutProviderCustomize] +public class PasswordManagerSignUpValidationStrategyTests +{ + [Theory] + [BitAutoData] + public void Validate_WhenPlanDoesNotAllowAdditionalStorageAndUpgradeRequestsAdditionalStorage_ThrowsBadRequestException( + SutProvider sutProvider, OrganizationUpgrade upgrade) + { + var plan = new Plan { HasAdditionalStorageOption = false, BitwardenProduct = BitwardenProductType.PasswordManager }; + upgrade.AdditionalStorageGb = 10; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + + [Theory] + [BitAutoData] + public void Validate_WhenUpgradeRequestsNegativeAdditionalStorage_ThrowsBadRequestException( + SutProvider sutProvider) + { + var upgrade = new OrganizationUpgrade(); + var plan = new Plan { HasAdditionalStorageOption = true, BitwardenProduct = BitwardenProductType.PasswordManager }; + upgrade.AdditionalStorageGb = -5; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + + [Theory] + [BitAutoData] + public void Validate_WhenNoSeatsAfterUpgrade_ThrowsBadRequestException( + SutProvider sutProvider) + { + var plan = new Plan(); + var upgrade = new OrganizationUpgrade(); + plan.BaseSeats = 5; + plan.BitwardenProduct = BitwardenProductType.PasswordManager; + upgrade.AdditionalSeats = -5; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + + [Theory] + [BitAutoData] + public void Validate_WhenUpgradeRequestsNegativeAdditionalSeats_ThrowsBadRequestException( + SutProvider sutProvider) + { + var plan = new Plan(); + var upgrade = new OrganizationUpgrade(); + plan.BitwardenProduct = BitwardenProductType.PasswordManager; + upgrade.AdditionalSeats = -3; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + + [Theory] + [BitAutoData] + public void Validate_WhenPlanDoesNotAllowAdditionalSeatsAndUpgradeRequestsAdditionalSeats_ThrowsBadRequestException( + SutProvider sutProvider) + { + var plan = new Plan(); + var upgrade = new OrganizationUpgrade(); + plan.HasAdditionalSeatsOption = false; + plan.BitwardenProduct = BitwardenProductType.PasswordManager; + upgrade.AdditionalSeats = 2; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSignUp/SecretsManagerSignUpValidationStrategyTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSignUp/SecretsManagerSignUpValidationStrategyTests.cs new file mode 100644 index 000000000000..836f9e33a82c --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationSignUp/SecretsManagerSignUpValidationStrategyTests.cs @@ -0,0 +1,90 @@ +using AutoFixture.Xunit2; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSignUp; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationSignUp; + +[SutProviderCustomize] +public class SecretsManagerSignUpValidationStrategyTests +{ + [Theory] + [BitAutoData] + public void Validate_WithInvalidAdditionalServiceAccountOption_ThrowsBadRequestException( + SutProvider sutProvider, [Frozen] Plan plan, + [Frozen] OrganizationUpgrade upgrade) + { + plan.HasAdditionalServiceAccountOption = false; + upgrade.AdditionalServiceAccount = 1; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + + [Theory] + [BitAutoData] + public void Validate_WithNegativeAdditionalServiceAccount_ThrowsBadRequestException( + SutProvider sutProvider, [Frozen] OrganizationUpgrade upgrade) + { + var plan = new Plan { HasAdditionalServiceAccountOption = true, BitwardenProduct = BitwardenProductType.PasswordManager }; + upgrade.AdditionalServiceAccount = -5; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + + [Theory] + [BitAutoData] + public void Validate_WithZeroSeats_ThrowsBadRequestException( + SutProvider sutProvider + , [Frozen] Plan plan, + [Frozen] OrganizationUpgrade upgrade) + { + plan.BaseSeats = 0; + upgrade.AdditionalSmSeats = -10; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + + [Theory] + [BitAutoData] + public void Validate_WithInvalidAdditionalSmSeatsOption_ThrowsBadRequestException( + SutProvider sutProvider, + [Frozen] Plan plan, + [Frozen] OrganizationUpgrade upgrade) + { + plan.HasAdditionalSeatsOption = false; + upgrade.AdditionalSmSeats = 5; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + + [Theory] + [BitAutoData] + public void Validate_WithNegativeAdditionalSmSeats_ThrowsBadRequestException( + SutProvider sutProvider, + [Frozen] OrganizationUpgrade upgrade) + { + var plan = new Plan { HasAdditionalSeatsOption = true, BitwardenProduct = BitwardenProductType.PasswordManager }; + upgrade.AdditionalSmSeats = -5; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } + + [Theory] + [BitAutoData] + public void Validate_WithExceededMaxAdditionalSmSeats_ThrowsBadRequestException( + SutProvider sutProvider, + [Frozen] Plan plan, + [Frozen] OrganizationUpgrade upgrade) + { + plan.HasAdditionalSeatsOption = true; + plan.MaxAdditionalSeats = 5; + upgrade.AdditionalSmSeats = 10; + + Assert.Throws(() => sutProvider.Sut.Validate(plan, upgrade)); + } +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/FamiliesForEnterpriseTestsBase.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/FamiliesForEnterpriseTestsBase.cs index e49b095d76fa..a3fce8b3de2b 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/FamiliesForEnterpriseTestsBase.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/FamiliesForEnterpriseTestsBase.cs @@ -6,16 +6,16 @@ namespace Bit.Core.Test.OrganizationFeatures.OrganizationSponsorships.FamiliesFo public abstract class FamiliesForEnterpriseTestsBase { public static IEnumerable EnterprisePlanTypes => - Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product == ProductType.Enterprise).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPasswordManagerPlan(p).Product == ProductType.Enterprise).Select(p => new object[] { p }); public static IEnumerable NonEnterprisePlanTypes => - Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product != ProductType.Enterprise).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPasswordManagerPlan(p).Product != ProductType.Enterprise).Select(p => new object[] { p }); public static IEnumerable FamiliesPlanTypes => - Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product == ProductType.Families).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPasswordManagerPlan(p).Product == ProductType.Families).Select(p => new object[] { p }); public static IEnumerable NonFamiliesPlanTypes => - Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product != ProductType.Families).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPasswordManagerPlan(p).Product != ProductType.Families).Select(p => new object[] { p }); public static IEnumerable NonConfirmedOrganizationUsersStatuses => Enum.GetValues() diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index 82131d874317..cfe9c83cc467 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -36,10 +36,26 @@ public async void PurchaseOrganizationAsync_Invalid(PaymentMethodType paymentMet Assert.Equal("Payment method is not supported at this time.", exception.Message); } + [Theory] + [BitAutoData(PaymentMethodType.BitPay)] + [BitAutoData(PaymentMethodType.BitPay)] + [BitAutoData(PaymentMethodType.Credit)] + [BitAutoData(PaymentMethodType.WireTransfer)] + [BitAutoData(PaymentMethodType.AppleInApp)] + [BitAutoData(PaymentMethodType.GoogleInApp)] + [BitAutoData(PaymentMethodType.Check)] + public async void PurchaseOrganizationWithProductsAsync_Invalid(PaymentMethodType paymentMethodType, SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PurchaseOrganizationWithProductsAsync(null, paymentMethodType, null, null, 0, 0, false, null)); + + Assert.Equal("Payment method is not supported at this time.", exception.Message); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_ProviderOrg_Coupon_Add(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo, bool provider = true) { - var plan = StaticStore.Plans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -86,10 +102,60 @@ await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo, bool provider = true) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + }); + + var result = await sutProvider.Sut.PurchaseOrganizationWithProductsAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo, provider); + + Assert.Null(result); + Assert.Equal(GatewayType.Stripe, organization.Gateway); + Assert.Equal("C-1", organization.GatewayCustomerId); + Assert.Equal("S-1", organization.GatewaySubscriptionId); + Assert.True(organization.Enabled); + Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate); + + await stripeAdapter.Received().CustomerCreateAsync(Arg.Is(c => + c.Description == organization.BusinessName && + c.Email == organization.BillingEmail && + c.Source == paymentToken && + c.PaymentMethod == null && + c.Coupon == "msp-discount-35" && + !c.Metadata.Any() && + c.InvoiceSettings.DefaultPaymentMethod == null && + c.Address.Country == taxInfo.BillingAddressCountry && + c.Address.PostalCode == taxInfo.BillingAddressPostalCode && + c.Address.Line1 == taxInfo.BillingAddressLine1 && + c.Address.Line2 == taxInfo.BillingAddressLine2 && + c.Address.City == taxInfo.BillingAddressCity && + c.Address.State == taxInfo.BillingAddressState && + c.TaxIdData == null + )); + + await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => + s.Customer == "C-1" && + s.Expand[0] == "latest_invoice.payment_intent" && + s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() && + s.Items.Count == 0 + )); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.Plans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -138,10 +204,62 @@ await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + }); + + var result = await sutProvider.Sut.PurchaseOrganizationWithProductsAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo); + + Assert.Null(result); + Assert.Equal(GatewayType.Stripe, organization.Gateway); + Assert.Equal("C-1", organization.GatewayCustomerId); + Assert.Equal("S-1", organization.GatewaySubscriptionId); + Assert.True(organization.Enabled); + Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate); + var res = organization.SubscriberName(); + await stripeAdapter.Received().CustomerCreateAsync(Arg.Is(c => + c.Description == organization.BusinessName && + c.Email == organization.BillingEmail && + c.Source == paymentToken && + c.PaymentMethod == null && + !c.Metadata.Any() && + c.InvoiceSettings.DefaultPaymentMethod == null && + c.InvoiceSettings.CustomFields != null && + c.InvoiceSettings.CustomFields[0].Name == "Organization" && + c.InvoiceSettings.CustomFields[0].Value == organization.SubscriberName().Substring(0, 30) && + c.Address.Country == taxInfo.BillingAddressCountry && + c.Address.PostalCode == taxInfo.BillingAddressPostalCode && + c.Address.Line1 == taxInfo.BillingAddressLine1 && + c.Address.Line2 == taxInfo.BillingAddressLine2 && + c.Address.City == taxInfo.BillingAddressCity && + c.Address.State == taxInfo.BillingAddressState && + c.TaxIdData == null + )); + + await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => + s.Customer == "C-1" && + s.Expand[0] == "latest_invoice.payment_intent" && + s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() && + s.Items.Count == 0 + )); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_PM(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.Plans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); paymentToken = "pm_" + paymentToken; var stripeAdapter = sutProvider.GetDependency(); @@ -191,10 +309,63 @@ await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually); + paymentToken = "pm_" + paymentToken; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + }); + + var result = await sutProvider.Sut.PurchaseOrganizationWithProductsAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo); + + Assert.Null(result); + Assert.Equal(GatewayType.Stripe, organization.Gateway); + Assert.Equal("C-1", organization.GatewayCustomerId); + Assert.Equal("S-1", organization.GatewaySubscriptionId); + Assert.True(organization.Enabled); + Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate); + + await stripeAdapter.Received().CustomerCreateAsync(Arg.Is(c => + c.Description == organization.BusinessName && + c.Email == organization.BillingEmail && + c.Source == null && + c.PaymentMethod == paymentToken && + !c.Metadata.Any() && + c.InvoiceSettings.DefaultPaymentMethod == paymentToken && + c.InvoiceSettings.CustomFields != null && + c.InvoiceSettings.CustomFields[0].Name == "Organization" && + c.InvoiceSettings.CustomFields[0].Value == organization.SubscriberName().Substring(0, 30) && + c.Address.Country == taxInfo.BillingAddressCountry && + c.Address.PostalCode == taxInfo.BillingAddressPostalCode && + c.Address.Line1 == taxInfo.BillingAddressLine1 && + c.Address.Line2 == taxInfo.BillingAddressLine2 && + c.Address.City == taxInfo.BillingAddressCity && + c.Address.State == taxInfo.BillingAddressState && + c.TaxIdData == null + )); + + await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => + s.Customer == "C-1" && + s.Expand[0] == "latest_invoice.payment_intent" && + s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() && + s.Items.Count == 0 + )); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_TaxRate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.Plans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -220,10 +391,39 @@ await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + }); + sutProvider.GetDependency().GetByLocationAsync(Arg.Is(t => + t.Country == taxInfo.BillingAddressCountry && t.PostalCode == taxInfo.BillingAddressPostalCode)) + .Returns(new List { new() { Id = "T-1" } }); + + var result = await sutProvider.Sut.PurchaseOrganizationWithProductsAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo); + + Assert.Null(result); + + await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => + s.DefaultTaxRates.Count == 1 && + s.DefaultTaxRates[0] == "T-1" + )); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.Plans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); paymentToken = "pm_" + paymentToken; var stripeAdapter = sutProvider.GetDependency(); @@ -253,10 +453,43 @@ public async void PurchaseOrganizationAsync_Stripe_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually); + paymentToken = "pm_" + paymentToken; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + Status = "incomplete", + LatestInvoice = new Stripe.Invoice + { + PaymentIntent = new Stripe.PaymentIntent + { + Status = "requires_payment_method", + }, + }, + }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PurchaseOrganizationWithProductsAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo)); + + Assert.Equal("Payment method was declined.", exception.Message); + + await stripeAdapter.Received(1).CustomerDeleteAsync("C-1"); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_RequiresAction(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.Plans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -284,10 +517,41 @@ public async void PurchaseOrganizationAsync_Stripe_RequiresAction(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + Status = "incomplete", + LatestInvoice = new Stripe.Invoice + { + PaymentIntent = new Stripe.PaymentIntent + { + Status = "requires_action", + ClientSecret = "clientSecret", + }, + }, + }); + + var result = await sutProvider.Sut.PurchaseOrganizationWithProductsAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo); + + Assert.Equal("clientSecret", result); + Assert.False(organization.Enabled); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Paypal(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.Plans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -343,10 +607,70 @@ await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + }); + + var customer = Substitute.For(); + customer.Id.ReturnsForAnyArgs("Braintree-Id"); + customer.PaymentMethods.ReturnsForAnyArgs(new[] { Substitute.For() }); + var customerResult = Substitute.For>(); + customerResult.IsSuccess().Returns(true); + customerResult.Target.ReturnsForAnyArgs(customer); + + var braintreeGateway = sutProvider.GetDependency(); + braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult); + + var result = await sutProvider.Sut.PurchaseOrganizationWithProductsAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo); + + Assert.Null(result); + Assert.Equal(GatewayType.Stripe, organization.Gateway); + Assert.Equal("C-1", organization.GatewayCustomerId); + Assert.Equal("S-1", organization.GatewaySubscriptionId); + Assert.True(organization.Enabled); + Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate); + + await stripeAdapter.Received().CustomerCreateAsync(Arg.Is(c => + c.Description == organization.BusinessName && + c.Email == organization.BillingEmail && + c.PaymentMethod == null && + c.Metadata.Count == 1 && + c.Metadata["btCustomerId"] == "Braintree-Id" && + c.InvoiceSettings.DefaultPaymentMethod == null && + c.Address.Country == taxInfo.BillingAddressCountry && + c.Address.PostalCode == taxInfo.BillingAddressPostalCode && + c.Address.Line1 == taxInfo.BillingAddressLine1 && + c.Address.Line2 == taxInfo.BillingAddressLine2 && + c.Address.City == taxInfo.BillingAddressCity && + c.Address.State == taxInfo.BillingAddressState && + c.TaxIdData == null + )); + + await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => + s.Customer == "C-1" && + s.Expand[0] == "latest_invoice.payment_intent" && + s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() && + s.Items.Count == 0 + )); + } + + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Paypal_FailedCreate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.Plans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); var customerResult = Substitute.For>(); customerResult.IsSuccess().Returns(false); @@ -360,10 +684,27 @@ public async void PurchaseOrganizationAsync_Paypal_FailedCreate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually); + + var customerResult = Substitute.For>(); + customerResult.IsSuccess().Returns(false); + + var braintreeGateway = sutProvider.GetDependency(); + braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PurchaseOrganizationWithProductsAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo)); + + Assert.Equal("Failed to create PayPal customer record.", exception.Message); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_PayPal_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.Plans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); paymentToken = "pm_" + paymentToken; var stripeAdapter = sutProvider.GetDependency(); @@ -404,6 +745,50 @@ public async void PurchaseOrganizationAsync_PayPal_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually); + paymentToken = "pm_" + paymentToken; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + Status = "incomplete", + LatestInvoice = new Stripe.Invoice + { + PaymentIntent = new Stripe.PaymentIntent + { + Status = "requires_payment_method", + }, + }, + }); + + var customer = Substitute.For(); + customer.Id.ReturnsForAnyArgs("Braintree-Id"); + customer.PaymentMethods.ReturnsForAnyArgs(new[] { Substitute.For() }); + var customerResult = Substitute.For>(); + customerResult.IsSuccess().Returns(true); + customerResult.Target.ReturnsForAnyArgs(customer); + + var braintreeGateway = sutProvider.GetDependency(); + braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PurchaseOrganizationWithProductsAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo)); + + Assert.Equal("Payment method was declined.", exception.Message); + + await stripeAdapter.Received(1).CustomerDeleteAsync("C-1"); + await braintreeGateway.Customer.Received(1).DeleteAsync("Braintree-Id"); + } + [Theory, BitAutoData] public async void UpgradeFreeOrganizationAsync_Success(SutProvider sutProvider, Organization organization, TaxInfo taxInfo) @@ -425,7 +810,7 @@ public async void UpgradeFreeOrganizationAsync_Success(SutProvider p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plan, 0, 0, false, taxInfo); Assert.Null(result); diff --git a/test/Core.Test/Utilities/StaticStoreTests.cs b/test/Core.Test/Utilities/StaticStoreTests.cs new file mode 100644 index 000000000000..d30a0e6c733b --- /dev/null +++ b/test/Core.Test/Utilities/StaticStoreTests.cs @@ -0,0 +1,70 @@ +using Bit.Core.Enums; +using Bit.Core.Models.StaticStore; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + + +public class StaticStoreTests +{ + [Fact] + public void StaticStore_Initialization_Success() + { + var plans = StaticStore.Plans; + Assert.NotNull(plans); + Assert.NotEmpty(plans); + Assert.Equal(17, plans.Count()); + } + + [Theory] + [InlineData(PlanType.EnterpriseAnnually)] + public void StaticStore_GetPasswordManagerPlanByPlanType_Success(PlanType planType) + { + var plan = StaticStore.GetPasswordManagerPlan(planType); + + Assert.NotNull(plan); + Assert.Equal(planType, plan.Type); + } + + [Theory] + [InlineData(PlanType.EnterpriseAnnually)] + public void StaticStore_GetSecretsManagerPlanByPlanType_Success(PlanType planType) + { + var plan = StaticStore.GetSecretsManagerPlan(planType); + + Assert.NotNull(plan); + Assert.Equal(planType, plan.Type); + } + + [Theory] + [InlineData(PlanType.EnterpriseAnnually)] + public void StaticStore_GetPasswordManagerPlan_ReturnsPasswordManagerPlans(PlanType planType) + { + var plan = StaticStore.GetPasswordManagerPlan(planType); + Assert.NotNull(plan); + Assert.Equal(BitwardenProductType.PasswordManager, plan.BitwardenProduct); + } + + [Theory] + [InlineData(PlanType.EnterpriseAnnually)] + public void StaticStore_GetSecretsManagerPlan_ReturnsSecretManagerPlans(PlanType planType) + { + var plan = StaticStore.GetSecretsManagerPlan(planType); + Assert.NotNull(plan); + Assert.Equal(BitwardenProductType.SecretsManager, plan.BitwardenProduct); + } + + [Theory] + [InlineData(PlanType.EnterpriseAnnually, BitwardenProductType.PasswordManager)] + public void StaticStore_AddDuplicatePlans_SingleOrDefaultThrowsException(PlanType planType, BitwardenProductType bitwardenProductType) + { + var plansStore = new List + { + new Plan { Type = PlanType.EnterpriseAnnually, BitwardenProduct = BitwardenProductType.PasswordManager }, + new Plan { Type = PlanType.EnterpriseAnnually, BitwardenProduct = BitwardenProductType.PasswordManager } + }; + + Assert.Throws(() => plansStore.SingleOrDefault(p => p.Type == planType && p.BitwardenProduct == bitwardenProductType)); + } +} diff --git a/util/EfShared/MigrationBuilderExtensions.cs b/util/EfShared/MigrationBuilderExtensions.cs index b59ad207fb6e..0ad02f74c479 100644 --- a/util/EfShared/MigrationBuilderExtensions.cs +++ b/util/EfShared/MigrationBuilderExtensions.cs @@ -19,7 +19,7 @@ public static class MigrationBuilderExtensions /// The MigrationBuilder instance the sql should be applied to /// The file name portion of the resource name, it is assumed to be in a Scripts folder /// The direction of the migration taking place - public static void SqlResource(this MigrationBuilder migrationBuilder, string resourceName, [CallerMemberName] string dir = null) + public static void SqlResource(this MigrationBuilder migrationBuilder, string resourceName, [CallerMemberName] string? dir = null) { var formattedResourceName = string.IsNullOrEmpty(dir) ? resourceName : string.Format(resourceName, dir); diff --git a/util/Migrator/DbScripts/2023-06-08_00_OrgUserReadOccupiedSmSeatCountByOrgId.sql b/util/Migrator/DbScripts/2023-06-08_00_OrgUserReadOccupiedSmSeatCountByOrgId.sql new file mode 100644 index 000000000000..bb31b1e6007b --- /dev/null +++ b/util/Migrator/DbScripts/2023-06-08_00_OrgUserReadOccupiedSmSeatCountByOrgId.sql @@ -0,0 +1,32 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(1) + FROM + [dbo].[OrganizationUserView] + WHERE + OrganizationId = @OrganizationId + AND Status >= 0 --Invited + AND AccessSecretsManager = 1 +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[ServiceAccount_ReadCountByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(1) + FROM + [dbo].[ServiceAccount] + WHERE + OrganizationId = @OrganizationId +END +GO +