diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs index 492c0c0d89..d880f5d024 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs @@ -130,6 +130,16 @@ public class ServiceAccountRepository : Repository GetServiceAccountCountByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + return await dbContext.ServiceAccount + .CountAsync(ou => ou.OrganizationId == organizationId); + } + } + private static Expression> UserHasReadAccessToServiceAccount(Guid userId) => sa => sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) || sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read)); diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index 4ef46459c3..0d87f48ff7 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -42,6 +42,8 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddOosServices(); + // Context services.AddScoped(); services.AddScoped(); diff --git a/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 3e46021795..35151dc4f6 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? AdditionalServiceAccounts { get; set; } + [Required] + public bool UseSecretsManager { get; set; } public virtual OrganizationSignup ToOrganizationSignup(User user) { @@ -58,6 +64,9 @@ public class OrganizationCreateRequestModel : IValidatableObject BillingEmail = BillingEmail, BusinessName = BusinessName, CollectionName = CollectionName, + AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(), + AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(), + 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 fb2666cc1e..0c5277ec6e 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs @@ -13,6 +13,12 @@ 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? AdditionalServiceAccounts { get; set; } + [Required] + public bool UseSecretsManager { get; set; } public bool PremiumAccessAddon { get; set; } public string BillingAddressCountry { get; set; } public string BillingAddressPostalCode { get; set; } @@ -24,6 +30,9 @@ public class OrganizationUpgradeRequestModel { AdditionalSeats = AdditionalSeats, AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(), + AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(0), + AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(0), + UseSecretsManager = UseSecretsManager, BusinessName = BusinessName, Plan = PlanType, PremiumAccessAddon = PremiumAccessAddon, diff --git a/src/Core/Models/Business/OrganizationUpgrade.cs b/src/Core/Models/Business/OrganizationUpgrade.cs index b77a9d012c..173502a24f 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? AdditionalServiceAccounts { get; set; } + public bool UseSecretsManager { get; set; } } diff --git a/src/Core/Models/Business/SubscriptionCreateOptions.cs b/src/Core/Models/Business/SubscriptionCreateOptions.cs index 4964a625c8..bd51f431f0 100644 --- a/src/Core/Models/Business/SubscriptionCreateOptions.cs +++ b/src/Core/Models/Business/SubscriptionCreateOptions.cs @@ -1,36 +1,61 @@ using Bit.Core.Entities; +using Bit.Core.Enums; using Stripe; namespace Bit.Core.Models.Business; public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOptions { - public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, TaxInfo taxInfo, int additionalSeats, int additionalStorageGb, bool premiumAccessAddon) + public OrganizationSubscriptionOptionsBase(Organization org, List plans, TaxInfo taxInfo, int additionalSeats, + int additionalStorageGb, bool premiumAccessAddon, int additionalSmSeats = 0, int additionalServiceAccounts = 0) { Items = new List(); Metadata = new Dictionary { [org.GatewayIdField()] = org.Id.ToString() }; + foreach (var plan in plans) + { + AddPlanIdToSubscription(plan); - if (plan.StripePlanId != null) + switch (plan.BitwardenProduct) + { + case BitwardenProductType.PasswordManager: + { + AddPremiumAccessAddon(premiumAccessAddon, plan); + AddAdditionalSeatToSubscription(additionalSeats, plan); + AddAdditionalStorage(additionalStorageGb, plan); + break; + } + case BitwardenProductType.SecretsManager: + { + AddAdditionalSeatToSubscription(additionalSmSeats, plan); + AddServiceAccount(additionalServiceAccounts, plan); + break; + } + } + } + + if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId)) + { + DefaultTaxRates = new List { taxInfo.StripeTaxRateId }; + } + } + + private void AddServiceAccount(int additionalServiceAccounts, StaticStore.Plan plan) + { + if (additionalServiceAccounts > 0 && plan.StripeServiceAccountPlanId != null) { Items.Add(new SubscriptionItemOptions { - Plan = plan.StripePlanId, - Quantity = 1 - }); - } - - if (additionalSeats > 0 && plan.StripeSeatPlanId != null) - { - Items.Add(new SubscriptionItemOptions - { - Plan = plan.StripeSeatPlanId, - Quantity = additionalSeats + Plan = plan.StripeServiceAccountPlanId, + Quantity = additionalServiceAccounts }); } + } + private void AddAdditionalStorage(int additionalStorageGb, StaticStore.Plan plan) + { if (additionalStorageGb > 0) { Items.Add(new SubscriptionItemOptions @@ -39,19 +64,29 @@ public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOpti Quantity = additionalStorageGb }); } + } + private void AddPremiumAccessAddon(bool premiumAccessAddon, StaticStore.Plan plan) + { if (premiumAccessAddon && plan.StripePremiumAccessPlanId != null) { - Items.Add(new SubscriptionItemOptions - { - Plan = plan.StripePremiumAccessPlanId, - Quantity = 1 - }); + Items.Add(new SubscriptionItemOptions { Plan = plan.StripePremiumAccessPlanId, Quantity = 1 }); } + } - if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId)) + private void AddAdditionalSeatToSubscription(int additionalSeats, StaticStore.Plan plan) + { + if (additionalSeats > 0 && plan.StripeSeatPlanId != null) { - DefaultTaxRates = new List { taxInfo.StripeTaxRateId }; + Items.Add(new SubscriptionItemOptions { Plan = plan.StripeSeatPlanId, Quantity = additionalSeats }); + } + } + + private void AddPlanIdToSubscription(StaticStore.Plan plan) + { + if (plan.StripePlanId != null) + { + Items.Add(new SubscriptionItemOptions { Plan = plan.StripePlanId, Quantity = 1 }); } } } @@ -59,13 +94,14 @@ public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOpti public class OrganizationPurchaseSubscriptionOptions : OrganizationSubscriptionOptionsBase { public OrganizationPurchaseSubscriptionOptions( - Organization org, StaticStore.Plan plan, + Organization org, List plans, TaxInfo taxInfo, int additionalSeats = 0, - int additionalStorageGb = 0, bool premiumAccessAddon = false) : - base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon) + int additionalStorageGb = 0, bool premiumAccessAddon = false, + int additionalSmSeats = 0, int additionalServiceAccounts = 0) : + base(org, plans, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon, additionalSmSeats, additionalServiceAccounts) { OffSession = true; - TrialPeriodDays = plan.TrialPeriodDays; + TrialPeriodDays = plans.FirstOrDefault(x => x.BitwardenProduct == BitwardenProductType.PasswordManager)!.TrialPeriodDays; } } @@ -73,10 +109,10 @@ public class OrganizationUpgradeSubscriptionOptions : OrganizationSubscriptionOp { public OrganizationUpgradeSubscriptionOptions( string customerId, Organization org, - StaticStore.Plan plan, TaxInfo taxInfo, + List plans, TaxInfo taxInfo, int additionalSeats = 0, int additionalStorageGb = 0, - bool premiumAccessAddon = false) : - base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon) + bool premiumAccessAddon = false, int additionalSmSeats = 0, int additionalServiceAccounts = 0) : + base(org, plans, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon, additionalSmSeats, additionalServiceAccounts) { Customer = customerId; } diff --git a/src/Core/Repositories/IOrganizationUserRepository.cs b/src/Core/Repositories/IOrganizationUserRepository.cs index 16d333f9e9..f9dfa12c2c 100644 --- a/src/Core/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/Repositories/IOrganizationUserRepository.cs @@ -40,4 +40,5 @@ public interface IOrganizationUserRepository : IRepository> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType); + Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId); } diff --git a/src/Core/Repositories/Noop/NoopServiceAccountRepository.cs b/src/Core/Repositories/Noop/NoopServiceAccountRepository.cs new file mode 100644 index 0000000000..526ff1cb88 --- /dev/null +++ b/src/Core/Repositories/Noop/NoopServiceAccountRepository.cs @@ -0,0 +1,60 @@ +using Bit.Core.Enums; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; + +namespace Bit.Core.Repositories.Noop; + +public class NoopServiceAccountRepository : IServiceAccountRepository +{ + public Task> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType) + { + return Task.FromResult(null as IEnumerable); + } + + public Task GetByIdAsync(Guid id) + { + return Task.FromResult(null as ServiceAccount); + } + + public Task> GetManyByIds(IEnumerable ids) + { + return Task.FromResult(null as IEnumerable); + } + + public Task CreateAsync(ServiceAccount serviceAccount) + { + return Task.FromResult(null as ServiceAccount); + } + + public Task ReplaceAsync(ServiceAccount serviceAccount) + { + return Task.FromResult(0); + } + + public Task DeleteManyByIdAsync(IEnumerable ids) + { + return Task.FromResult(0); + } + + public Task UserHasReadAccessToServiceAccount(Guid id, Guid userId) + { + return Task.FromResult(false); + } + + public Task UserHasWriteAccessToServiceAccount(Guid id, Guid userId) + { + return Task.FromResult(false); + } + + public Task> GetManyByOrganizationIdWriteAccessAsync(Guid organizationId, Guid userId, AccessClientType accessType) => throw new NotImplementedException(); + + public Task<(bool Read, bool Write)> AccessToServiceAccountAsync(Guid id, Guid userId, AccessClientType accessType) + { + return Task.FromResult((false, false)); + } + + public Task GetServiceAccountCountByOrganizationIdAsync(Guid organizationId) + { + return Task.FromResult(0); + } +} diff --git a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs index d79ed020a3..b362a5676b 100644 --- a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs +++ b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs @@ -15,4 +15,5 @@ public interface IServiceAccountRepository Task UserHasWriteAccessToServiceAccount(Guid id, Guid userId); Task> GetManyByOrganizationIdWriteAccessAsync(Guid organizationId, Guid userId, AccessClientType accessType); Task<(bool Read, bool Write)> AccessToServiceAccountAsync(Guid id, Guid userId, AccessClientType accessType); + Task GetServiceAccountCountByOrganizationIdAsync(Guid organizationId); } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 95c6f25908..ba482e0d59 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -9,12 +9,14 @@ public interface IPaymentService { Task CancelAndRecoverChargesAsync(ISubscriber subscriber); Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, - string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats, - bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false); + string paymentToken, List 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, - short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo); + Task UpgradeFreeOrganizationAsync(Organization org, List plans, + short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, + int additionalSmSeats = 0, int additionalServiceAccounts = 0); Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb, TaxInfo taxInfo); Task AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 94f03c8971..abf420392b 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -13,6 +13,7 @@ using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.Policies; using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; using Bit.Core.Settings; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; @@ -53,6 +54,7 @@ public class OrganizationService : IOrganizationService private readonly ILogger _logger; private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderUserRepository _providerUserRepository; + private readonly IServiceAccountRepository _serviceAccountRepository; public OrganizationService( IOrganizationRepository organizationRepository, @@ -81,7 +83,8 @@ public class OrganizationService : IOrganizationService ICurrentContext currentContext, ILogger logger, IProviderOrganizationRepository providerOrganizationRepository, - IProviderUserRepository providerUserRepository) + IProviderUserRepository providerUserRepository, + IServiceAccountRepository serviceAccountRepository) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -110,6 +113,7 @@ public class OrganizationService : IOrganizationService _logger = logger; _providerOrganizationRepository = providerOrganizationRepository; _providerUserRepository = providerUserRepository; + _serviceAccountRepository = serviceAccountRepository; } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -179,90 +183,99 @@ public class OrganizationService : IOrganizationService throw new BadRequestException("Your account has no payment method available."); } - var existingPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); - if (existingPlan == null) + var existingPasswordManagerPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); + if (existingPasswordManagerPlan == null) { throw new BadRequestException("Existing plan not found."); } - var newPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); - if (newPlan == null) + var newPasswordManagerPlan = + StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); + if (newPasswordManagerPlan == null) { throw new BadRequestException("Plan not found."); } - if (existingPlan.Type == newPlan.Type) + if (existingPasswordManagerPlan.Type == newPasswordManagerPlan.Type) { throw new BadRequestException("Organization is already on this plan."); } - if (existingPlan.UpgradeSortOrder >= newPlan.UpgradeSortOrder) + if (existingPasswordManagerPlan.UpgradeSortOrder >= newPasswordManagerPlan.UpgradeSortOrder) { throw new BadRequestException("You cannot upgrade to this plan."); } - if (existingPlan.Type != PlanType.Free) + if (existingPasswordManagerPlan.Type != PlanType.Free) { throw new BadRequestException("You can only upgrade from the free plan. Contact support."); } - ValidateOrganizationUpgradeParameters(newPlan, upgrade); - - var newPlanSeats = (short)(newPlan.BaseSeats + - (newPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0)); - if (!organization.Seats.HasValue || organization.Seats.Value > newPlanSeats) + ValidatePasswordManagerPlan(newPasswordManagerPlan, upgrade); + var newSecretsManagerPlan = + StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); + if (upgrade.UseSecretsManager) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - if (occupiedSeats > newPlanSeats) + ValidateSecretsManagerPlan(newSecretsManagerPlan, upgrade); + } + + var newPasswordManagerPlanSeats = (short)(newPasswordManagerPlan.BaseSeats + + (newPasswordManagerPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0)); + if (!organization.Seats.HasValue || organization.Seats.Value > newPasswordManagerPlanSeats) + { + var occupiedSeats = + await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + if (occupiedSeats > newPasswordManagerPlanSeats) { throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " + - $"Your new plan only has ({newPlanSeats}) seats. Remove some users."); + $"Your new plan only has ({newPasswordManagerPlanSeats}) seats. Remove some users."); } } - if (newPlan.MaxCollections.HasValue && (!organization.MaxCollections.HasValue || - organization.MaxCollections.Value > newPlan.MaxCollections.Value)) + if (newPasswordManagerPlan.MaxCollections.HasValue && (!organization.MaxCollections.HasValue || + organization.MaxCollections.Value > + newPasswordManagerPlan.MaxCollections.Value)) { var collectionCount = await _collectionRepository.GetCountByOrganizationIdAsync(organization.Id); - if (collectionCount > newPlan.MaxCollections.Value) + if (collectionCount > newPasswordManagerPlan.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."); + $"Your new plan allows for a maximum of ({newPasswordManagerPlan.MaxCollections.Value}) collections. " + + "Remove some collections."); } } - if (!newPlan.HasGroups && organization.UseGroups) + if (!newPasswordManagerPlan.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."); + $"Remove your groups."); } } - if (!newPlan.HasPolicies && organization.UsePolicies) + if (!newPasswordManagerPlan.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."); + $"Disable your policies."); } } - if (!newPlan.HasSso && organization.UseSso) + if (!newPasswordManagerPlan.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."); + $"Disable your SSO configuration."); } } - if (!newPlan.HasKeyConnector && organization.UseKeyConnector) + if (!newPasswordManagerPlan.HasKeyConnector && organization.UseKeyConnector) { var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id); if (ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.KeyConnector) @@ -272,29 +285,29 @@ public class OrganizationService : IOrganizationService } } - if (!newPlan.HasResetPassword && organization.UseResetPassword) + if (!newPasswordManagerPlan.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."); + "Disable your Password Reset policy."); } } - if (!newPlan.HasScim && organization.UseScim) + if (!newPasswordManagerPlan.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."); + "Disable your SCIM configuration."); } } - if (!newPlan.HasCustomPermissions && organization.UseCustomPermissions) + if (!newPasswordManagerPlan.HasCustomPermissions && organization.UseCustomPermissions) { var organizationCustomUsers = await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id, @@ -306,14 +319,25 @@ public class OrganizationService : IOrganizationService } } - // TODO: Check storage? + if (upgrade.UseSecretsManager && newSecretsManagerPlan != null) + { + await ValidateSecretsManagerSeatsAndServiceAccountAsync(upgrade, organization, newSecretsManagerPlan); + } + // TODO: Check storage? string paymentIntentClientSecret = null; var success = true; + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { - paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, newPlan, - upgrade.AdditionalStorageGb, upgrade.AdditionalSeats, upgrade.PremiumAccessAddon, upgrade.TaxInfo); + var organizationUpgradePlan = upgrade.UseSecretsManager + ? StaticStore.Plans.Where(p => p.Type == upgrade.Plan).ToList() + : StaticStore.Plans.Where(p => p.Type == upgrade.Plan && p.BitwardenProduct == BitwardenProductType.PasswordManager).ToList(); + + paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, + organizationUpgradePlan, + upgrade.AdditionalStorageGb, upgrade.AdditionalSeats, upgrade.PremiumAccessAddon, upgrade.TaxInfo + , upgrade.AdditionalSmSeats.GetValueOrDefault(), upgrade.AdditionalServiceAccounts.GetValueOrDefault()); success = string.IsNullOrWhiteSpace(paymentIntentClientSecret); } else @@ -323,54 +347,100 @@ public class OrganizationService : IOrganizationService } organization.BusinessName = upgrade.BusinessName; - organization.PlanType = newPlan.Type; - organization.Seats = (short)(newPlan.BaseSeats + upgrade.AdditionalSeats); - organization.MaxCollections = newPlan.MaxCollections; - organization.UseGroups = newPlan.HasGroups; - organization.UseDirectory = newPlan.HasDirectory; - organization.UseEvents = newPlan.HasEvents; - organization.UseTotp = newPlan.HasTotp; - organization.Use2fa = newPlan.Has2fa; - organization.UseApi = newPlan.HasApi; - organization.SelfHost = newPlan.HasSelfHost; - organization.UsePolicies = newPlan.HasPolicies; - organization.MaxStorageGb = !newPlan.BaseStorageGb.HasValue ? - (short?)null : (short)(newPlan.BaseStorageGb.Value + upgrade.AdditionalStorageGb); - organization.UseGroups = newPlan.HasGroups; - organization.UseDirectory = newPlan.HasDirectory; - organization.UseEvents = newPlan.HasEvents; - organization.UseTotp = newPlan.HasTotp; - organization.Use2fa = newPlan.Has2fa; - organization.UseApi = newPlan.HasApi; - organization.UseSso = newPlan.HasSso; - organization.UseKeyConnector = newPlan.HasKeyConnector; - organization.UseScim = newPlan.HasScim; - organization.UseResetPassword = newPlan.HasResetPassword; - organization.SelfHost = newPlan.HasSelfHost; - organization.UsersGetPremium = newPlan.UsersGetPremium || upgrade.PremiumAccessAddon; - organization.UseCustomPermissions = newPlan.HasCustomPermissions; - organization.Plan = newPlan.Name; + organization.PlanType = newPasswordManagerPlan.Type; + organization.Seats = (short)(newPasswordManagerPlan.BaseSeats + upgrade.AdditionalSeats); + organization.MaxCollections = newPasswordManagerPlan.MaxCollections; + organization.UseGroups = newPasswordManagerPlan.HasGroups; + organization.UseDirectory = newPasswordManagerPlan.HasDirectory; + organization.UseEvents = newPasswordManagerPlan.HasEvents; + organization.UseTotp = newPasswordManagerPlan.HasTotp; + organization.Use2fa = newPasswordManagerPlan.Has2fa; + organization.UseApi = newPasswordManagerPlan.HasApi; + organization.SelfHost = newPasswordManagerPlan.HasSelfHost; + organization.UsePolicies = newPasswordManagerPlan.HasPolicies; + organization.MaxStorageGb = !newPasswordManagerPlan.BaseStorageGb.HasValue + ? (short?)null + : (short)(newPasswordManagerPlan.BaseStorageGb.Value + upgrade.AdditionalStorageGb); + organization.UseGroups = newPasswordManagerPlan.HasGroups; + organization.UseDirectory = newPasswordManagerPlan.HasDirectory; + organization.UseEvents = newPasswordManagerPlan.HasEvents; + organization.UseTotp = newPasswordManagerPlan.HasTotp; + organization.Use2fa = newPasswordManagerPlan.Has2fa; + organization.UseApi = newPasswordManagerPlan.HasApi; + organization.UseSso = newPasswordManagerPlan.HasSso; + organization.UseKeyConnector = newPasswordManagerPlan.HasKeyConnector; + organization.UseScim = newPasswordManagerPlan.HasScim; + organization.UseResetPassword = newPasswordManagerPlan.HasResetPassword; + organization.SelfHost = newPasswordManagerPlan.HasSelfHost; + organization.UsersGetPremium = newPasswordManagerPlan.UsersGetPremium || upgrade.PremiumAccessAddon; + organization.UseCustomPermissions = newPasswordManagerPlan.HasCustomPermissions; + organization.Plan = newPasswordManagerPlan.Name; organization.Enabled = success; organization.PublicKey = upgrade.PublicKey; organization.PrivateKey = upgrade.PrivateKey; + organization.UsePasswordManager = true; + organization.SmSeats = (short)(newSecretsManagerPlan.BaseSeats + upgrade.AdditionalSmSeats.GetValueOrDefault()); + organization.SmServiceAccounts = upgrade.AdditionalServiceAccounts.GetValueOrDefault(); + organization.UseSecretsManager = upgrade.UseSecretsManager; + await ReplaceAndUpdateCacheAsync(organization); if (success) { await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.UpgradePlan, organization, _currentContext) { - PlanName = newPlan.Name, - PlanType = newPlan.Type, - OldPlanName = existingPlan.Name, - OldPlanType = existingPlan.Type, + PlanName = newPasswordManagerPlan.Name, + PlanType = newPasswordManagerPlan.Type, + OldPlanName = existingPasswordManagerPlan.Name, + OldPlanType = existingPasswordManagerPlan.Type, Seats = organization.Seats, Storage = organization.MaxStorageGb, + SmSeats = organization.SmSeats, + ServiceAccounts = organization.SmServiceAccounts, + UseSecretsManager = organization.UseSecretsManager }); } return new Tuple(success, paymentIntentClientSecret); } + private async Task ValidateSecretsManagerSeatsAndServiceAccountAsync(OrganizationUpgrade upgrade, Organization organization, + Models.StaticStore.Plan newSecretsManagerPlan) + { + var newPlanSmSeats = (short)(newSecretsManagerPlan.BaseSeats + + (newSecretsManagerPlan.HasAdditionalSeatsOption + ? upgrade.AdditionalSmSeats + : 0)); + var occupiedSmSeats = + await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + + if (!organization.SmSeats.HasValue || organization.SmSeats.Value > newPlanSmSeats) + { + if (occupiedSmSeats > newPlanSmSeats) + { + throw new BadRequestException( + $"Your organization currently has {occupiedSmSeats} Secrets Manager seats filled. " + + $"Your new plan only has ({newPlanSmSeats}) seats. Remove some users."); + } + } + + if (newSecretsManagerPlan.BaseServiceAccount != null) + { + if (!organization.SmServiceAccounts.HasValue || + organization.SmServiceAccounts.Value > newSecretsManagerPlan.MaxServiceAccount) + { + var currentServiceAccounts = + await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id); + if (currentServiceAccounts > newSecretsManagerPlan.MaxServiceAccount) + { + throw new BadRequestException( + $"Your organization currently has {currentServiceAccounts} service account seats filled. " + + $"Your new plan only has ({newSecretsManagerPlan.MaxServiceAccount}) service accounts. Remove some service accounts."); + } + } + } + } + public async Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb) { var organization = await GetOrgById(organizationId); @@ -607,15 +677,14 @@ public class OrganizationService : IOrganizationService public async Task> SignUpAsync(OrganizationSignup signup, bool provider = false) { - var plan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == signup.Plan); - if (plan is not { LegacyYear: null }) - { - throw new BadRequestException("Invalid plan selected."); - } + var passwordManagerPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == signup.Plan); - if (plan.Disabled) + ValidatePasswordManagerPlan(passwordManagerPlan, signup); + + var secretsManagerPlan = StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == signup.Plan); + if (signup.UseSecretsManager) { - throw new BadRequestException("Plan not found."); + ValidateSecretsManagerPlan(secretsManagerPlan, signup); } if (!provider) @@ -623,8 +692,6 @@ public class OrganizationService : IOrganizationService await ValidateSignUpPoliciesAsync(signup.Owner.Id); } - ValidateOrganizationUpgradeParameters(plan, signup); - var organization = new Organization { // Pre-generate the org id so that we can save it with the Stripe subscription.. @@ -632,25 +699,25 @@ public class OrganizationService : IOrganizationService Name = signup.Name, BillingEmail = signup.BillingEmail, BusinessName = signup.BusinessName, - PlanType = plan.Type, - Seats = (short)(plan.BaseSeats + signup.AdditionalSeats), - MaxCollections = plan.MaxCollections, - MaxStorageGb = !plan.BaseStorageGb.HasValue ? - (short?)null : (short)(plan.BaseStorageGb.Value + signup.AdditionalStorageGb), - UsePolicies = plan.HasPolicies, - UseSso = plan.HasSso, - UseGroups = plan.HasGroups, - UseEvents = plan.HasEvents, - UseDirectory = plan.HasDirectory, - UseTotp = plan.HasTotp, - Use2fa = plan.Has2fa, - UseApi = plan.HasApi, - UseResetPassword = plan.HasResetPassword, - SelfHost = plan.HasSelfHost, - UsersGetPremium = plan.UsersGetPremium || signup.PremiumAccessAddon, - UseCustomPermissions = plan.HasCustomPermissions, - UseScim = plan.HasScim, - Plan = plan.Name, + 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, @@ -659,10 +726,14 @@ public class OrganizationService : IOrganizationService PrivateKey = signup.PrivateKey, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, - Status = OrganizationStatusType.Created + Status = OrganizationStatusType.Created, + UsePasswordManager = true, + SmSeats = (short)(secretsManagerPlan.BaseSeats + signup.AdditionalSmSeats.GetValueOrDefault()), + SmServiceAccounts = signup.AdditionalServiceAccounts.GetValueOrDefault(), + UseSecretsManager = signup.UseSecretsManager }; - if (plan.Type == PlanType.Free && !provider) + if (passwordManagerPlan.Type == PlanType.Free && !provider) { var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id); @@ -671,11 +742,16 @@ public class OrganizationService : IOrganizationService throw new BadRequestException("You can only be an admin of one free organization."); } } - else if (plan.Type != PlanType.Free) + else if (passwordManagerPlan.Type != PlanType.Free) { + var purchaseOrganizationPlan = signup.UseSecretsManager + ? StaticStore.Plans.Where(p => p.Type == signup.Plan).ToList() + : StaticStore.PasswordManagerPlans.Where(p => p.Type == signup.Plan).Take(1).ToList(); + await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, - signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, - signup.PremiumAccessAddon, signup.TaxInfo, provider); + signup.PaymentToken, purchaseOrganizationPlan, signup.AdditionalStorageGb, signup.AdditionalSeats, + signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault()); } var ownerId = provider ? default : signup.Owner.Id; @@ -683,10 +759,13 @@ public class OrganizationService : IOrganizationService await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) { - PlanName = plan.Name, - PlanType = plan.Type, + PlanName = passwordManagerPlan.Name, + PlanType = passwordManagerPlan.Type, Seats = returnValue.Item1.Seats, Storage = returnValue.Item1.MaxStorageGb, + SmSeats = returnValue.Item1.SmSeats, + ServiceAccounts = returnValue.Item1.SmServiceAccounts, + UseSecretsManager = returnValue.Item1.UseSecretsManager }); return returnValue; } @@ -2060,8 +2139,43 @@ public class OrganizationService : IOrganizationService return await _organizationRepository.GetByIdAsync(id); } - private void ValidateOrganizationUpgradeParameters(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade) + private static void ValidatePlan(Models.StaticStore.Plan plan, int additionalSeats, string productType) { + if (plan is not { LegacyYear: null }) + { + throw new BadRequestException($"Invalid {productType} plan selected."); + } + + if (plan.Disabled) + { + throw new BadRequestException($"{productType} Plan not found."); + } + + if (plan.BaseSeats + additionalSeats <= 0) + { + throw new BadRequestException($"You do not have any {productType} seats!"); + } + + if (additionalSeats < 0) + { + throw new BadRequestException($"You can't subtract {productType} seats!"); + } + } + + private static void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade) + { + ValidatePlan(plan, upgrade.AdditionalSeats, "Password Manager"); + + if (plan.BaseSeats + upgrade.AdditionalSeats <= 0) + { + throw new BadRequestException($"You do not have any Password Manager seats!"); + } + + if (upgrade.AdditionalSeats < 0) + { + throw new BadRequestException($"You can't subtract Password Manager seats!"); + } + if (!plan.HasAdditionalStorageOption && upgrade.AdditionalStorageGb > 0) { throw new BadRequestException("Plan does not allow additional storage."); @@ -2077,16 +2191,6 @@ public class OrganizationService : IOrganizationService throw new BadRequestException("This plan does not allow you to buy the premium access addon."); } - 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!"); - } - if (!plan.HasAdditionalSeatsOption && upgrade.AdditionalSeats > 0) { throw new BadRequestException("Plan does not allow additional users."); @@ -2096,7 +2200,37 @@ public class OrganizationService : IOrganizationService upgrade.AdditionalSeats > plan.MaxAdditionalSeats.Value) { throw new BadRequestException($"Selected plan allows a maximum of " + - $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); + $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); + } + } + + private static void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade) + { + ValidatePlan(plan, upgrade.AdditionalSmSeats.GetValueOrDefault(), "Secrets Manager"); + + if (!plan.HasAdditionalServiceAccountOption && upgrade.AdditionalServiceAccounts > 0) + { + throw new BadRequestException("Plan does not allow additional Service Accounts."); + } + + if (upgrade.AdditionalSmSeats.GetValueOrDefault() > upgrade.AdditionalSeats) + { + throw new BadRequestException("You cannot have more Secrets Manager seats than Password Manager seats."); + } + + if (upgrade.AdditionalServiceAccounts.GetValueOrDefault() < 0) + { + throw new BadRequestException("You can't subtract Service Accounts!"); + } + + switch (plan.HasAdditionalSeatsOption) + { + case false when upgrade.AdditionalSmSeats > 0: + throw new BadRequestException("Plan does not allow additional users."); + case true when 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/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index ba99561534..bc0507de2f 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -45,8 +45,9 @@ public class StripePaymentService : IPaymentService } public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, - string paymentToken, StaticStore.Plan plan, short additionalStorageGb, - int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false) + string paymentToken, List 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; @@ -110,7 +111,8 @@ public class StripePaymentService : IPaymentService } } - var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon); + var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plans, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon + , additionalSmSeats, additionalServiceAccount); Stripe.Customer customer = null; Stripe.Subscription subscription; @@ -221,8 +223,9 @@ public class StripePaymentService : IPaymentService public Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship) => ChangeOrganizationSponsorship(org, sponsorship, false); - public async Task UpgradeFreeOrganizationAsync(Organization org, StaticStore.Plan plan, - short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo) + public async Task UpgradeFreeOrganizationAsync(Organization org, List plans, + short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, + int additionalSmSeats = 0, int additionalServiceAccounts = 0) { if (!string.IsNullOrWhiteSpace(org.GatewaySubscriptionId)) { @@ -255,7 +258,7 @@ public class StripePaymentService : IPaymentService } } - var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon); + var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plans, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon); var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions); var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, diff --git a/src/Core/Tools/Models/Business/ReferenceEvent.cs b/src/Core/Tools/Models/Business/ReferenceEvent.cs index 393708668a..382d9cf190 100644 --- a/src/Core/Tools/Models/Business/ReferenceEvent.cs +++ b/src/Core/Tools/Models/Business/ReferenceEvent.cs @@ -70,4 +70,7 @@ public class ReferenceEvent public string ClientId { get; set; } public Version ClientVersion { get; set; } + public int? SmSeats { get; set; } + public int? ServiceAccounts { get; set; } + public bool UseSecretsManager { get; set; } } diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 6a0f274ce8..5ba20d905b 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -46,6 +46,8 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddOosServices(); + // Context services.AddScoped(); services.TryAddSingleton(); @@ -151,6 +153,7 @@ public class Startup }); } + public void Configure( IApplicationBuilder app, IWebHostEnvironment env, diff --git a/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs index ca24694908..008242c26c 100644 --- a/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs @@ -99,6 +99,19 @@ public class OrganizationUserRepository : Repository, IO } } + 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> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, bool onlyRegisteredUsers) { diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs index 282304720f..8256696d96 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs @@ -621,4 +621,11 @@ public class OrganizationUserRepository : Repository GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId) + { + var query = new OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery(organizationId); + return await GetCountFromQuery(query); + } + } diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery.cs new file mode 100644 index 0000000000..0f21a80ba6 --- /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/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index c239be969a..355f4b46c4 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ using Bit.Core.IdentityServer; using Bit.Core.OrganizationFeatures; using Bit.Core.Repositories; using Bit.Core.Resources; +using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; @@ -329,6 +330,7 @@ public static class ServiceCollectionExtensions public static void AddOosServices(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } public static void AddNoopServices(this IServiceCollection services) 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 0000000000..3c3792effe --- /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/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index b208ddecb0..b2c73c24fc 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Models.StaticStore; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -22,6 +23,7 @@ using Bit.Core.Test.AutoFixture.PolicyFixtures; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -174,6 +176,20 @@ public class OrganizationServiceTests Assert.Contains("already on this plan", exception.Message); } + [Theory, BitAutoData] + public async Task UpgradePlan_SM_AlreadyInPlan_Throws(Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + upgrade.Plan = organization.PlanType; + upgrade.UseSecretsManager = true; + upgrade.AdditionalSmSeats = 10; + upgrade.AdditionalServiceAccounts = 10; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Contains("already on this plan", exception.Message); + } + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData] public async Task UpgradePlan_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade, SutProvider sutProvider) @@ -184,16 +200,147 @@ public class OrganizationServiceTests Assert.Contains("can only upgrade", exception.Message); } + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData] + public async Task UpgradePlan_SM_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + upgrade.UseSecretsManager = true; + upgrade.AdditionalSmSeats = 10; + upgrade.AdditionalServiceAccounts = 10; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Contains("can only upgrade", exception.Message); + } + [Theory] [FreeOrganizationUpgradeCustomize, BitAutoData] public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade, SutProvider sutProvider) { sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + upgrade.AdditionalSmSeats = 10; + upgrade.AdditionalSeats = 10; await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); await sutProvider.GetDependency().Received(1).ReplaceAsync(organization); } + [Theory] + [BitAutoData] + public async Task SignUp_SM_Passes(OrganizationSignup signup, SutProvider sutProvider) + { + signup.AdditionalSmSeats = 10; + signup.AdditionalSeats = 10; + signup.Plan = PlanType.EnterpriseAnnually; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + + var purchaseOrganizationPlan = StaticStore.Plans.Where(x => x.Type == signup.Plan).ToList(); + + var result = await sutProvider.Sut.SignUpAsync(signup); + + await sutProvider.GetDependency().Received(1) + .RaiseEventAsync(Arg.Is(referenceEvent => + referenceEvent.Type == ReferenceEventType.Signup && + referenceEvent.PlanName == purchaseOrganizationPlan[0].Name && + referenceEvent.PlanType == purchaseOrganizationPlan[0].Type && + referenceEvent.Seats == result.Item1.Seats && + referenceEvent.SmSeats == result.Item1.SmSeats && + referenceEvent.ServiceAccounts == result.Item1.SmServiceAccounts && + referenceEvent.UseSecretsManager == result.Item1.UseSecretsManager && + referenceEvent.Storage == result.Item1.MaxStorageGb)); + + Assert.NotNull(result); + Assert.NotNull(result.Item1); + Assert.NotNull(result.Item2); + Assert.IsType>(result); + + await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( + Arg.Any(), + signup.PaymentMethodType.Value, + signup.PaymentToken, + Arg.Is>(plan => plan.All(p => purchaseOrganizationPlan.Contains(p))), + signup.AdditionalStorageGb, + signup.AdditionalSeats, + signup.PremiumAccessAddon, + signup.TaxInfo, + false, + signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault() + ); + } + + [Theory] + [BitAutoData] + public async Task SignUpAsync_SecretManagerValidation_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) + { + signup.AdditionalSmSeats = 10; + signup.AdditionalSeats = 10; + signup.Plan = PlanType.EnterpriseAnnually; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.AdditionalServiceAccounts = 10; + + var purchaseOrganizationPlan = StaticStore.Plans.Where(x => x.Type == signup.Plan && x.BitwardenProduct == BitwardenProductType.PasswordManager).ToList(); + var secretsManagerPlan = StaticStore.GetSecretsManagerPlan(PlanType.EnterpriseAnnually); + secretsManagerPlan.HasAdditionalServiceAccountOption = false; + purchaseOrganizationPlan.Add(secretsManagerPlan); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(signup)); + Assert.Contains("Plan does not allow additional Service Accounts.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SignUpAsync_SMSeatsGreatThanPMSeat_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) + { + signup.AdditionalSmSeats = 100; + signup.AdditionalSeats = 10; + signup.Plan = PlanType.EnterpriseAnnually; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.AdditionalServiceAccounts = 10; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(signup)); + Assert.Contains("You cannot have more Secrets Manager seats than Password Manager seats", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SignUpAsync_InvalidateServiceAccount_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) + { + signup.AdditionalSmSeats = 10; + signup.AdditionalSeats = 10; + signup.Plan = PlanType.EnterpriseAnnually; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.AdditionalServiceAccounts = -10; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(signup)); + Assert.Contains("You can't subtract Service Accounts!", exception.Message); + } + + [Theory] + [FreeOrganizationUpgradeCustomize, BitAutoData] + public async Task UpgradePlan_SM_Passes(Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + upgrade.AdditionalSmSeats = 10; + upgrade.AdditionalSeats = 10; + var result = await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); + await sutProvider.GetDependency().Received(1).ReplaceAsync(organization); + Assert.True(result.Item1); + Assert.NotNull(result.Item2); + } + [Theory] [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Owner), BitAutoData] @@ -1469,4 +1616,5 @@ public class OrganizationServiceTests Assert.Equal(includeProvider, result); } + } diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index c43e99fb98..96f9a6a6f3 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -31,7 +31,7 @@ public class StripePaymentServiceTests public async void PurchaseOrganizationAsync_Invalid(PaymentMethodType paymentMethodType, SutProvider sutProvider) { var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PurchaseOrganizationAsync(null, paymentMethodType, null, null, 0, 0, false, null)); + () => sutProvider.Sut.PurchaseOrganizationAsync(null, paymentMethodType, null, null, 0, 0, false, null, false, -1, -1)); Assert.Equal("Payment method is not supported at this time.", exception.Message); } @@ -39,7 +39,7 @@ public class StripePaymentServiceTests [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_ProviderOrg_Coupon_Add(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo, bool provider = true) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -52,7 +52,7 @@ public class StripePaymentServiceTests CurrentPeriodEnd = DateTime.Today.AddDays(10), }); - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo, provider); + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo, provider); Assert.Null(result); Assert.Equal(GatewayType.Stripe, organization.Gateway); @@ -86,10 +86,63 @@ public class StripePaymentServiceTests )); } + [Theory, BitAutoData] + public async void PurchaseOrganizationAsync_SM_Stripe_ProviderOrg_Coupon_Add(SutProvider sutProvider, Organization organization, + string paymentToken, TaxInfo taxInfo, bool provider = true) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + 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.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 1, 1, + false, taxInfo, provider, 1, 1); + + 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 == 4 + )); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -102,7 +155,8 @@ public class StripePaymentServiceTests CurrentPeriodEnd = DateTime.Today.AddDays(10), }); - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo); + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0 + , false, taxInfo, false, 8, 10); Assert.Null(result); Assert.Equal(GatewayType.Stripe, organization.Gateway); @@ -110,7 +164,6 @@ public class StripePaymentServiceTests 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 && @@ -134,67 +187,14 @@ public class StripePaymentServiceTests 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.PasswordManagerPlans.First(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.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 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 + s.Items.Count == 2 )); } [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_TaxRate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -210,7 +210,37 @@ public class StripePaymentServiceTests t.Country == taxInfo.BillingAddressCountry && t.PostalCode == taxInfo.BillingAddressPostalCode)) .Returns(new List { new() { Id = "T-1" } }); - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo); + var result = await sutProvider.Sut.PurchaseOrganizationAsync(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_TaxRate_SM(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + 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.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 2, 2, + false, taxInfo, false, 2, 2); Assert.Null(result); @@ -223,7 +253,7 @@ public class StripePaymentServiceTests [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); paymentToken = "pm_" + paymentToken; var stripeAdapter = sutProvider.GetDependency(); @@ -253,10 +283,44 @@ public class StripePaymentServiceTests await stripeAdapter.Received(1).CustomerDeleteAsync("C-1"); } + [Theory, BitAutoData] + public async void PurchaseOrganizationAsync_SM_Stripe_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plan = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + 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.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, + 1, 12, false, taxInfo, false, 10, 10)); + + 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.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -278,7 +342,39 @@ public class StripePaymentServiceTests }, }); - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo); + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo); + + Assert.Equal("clientSecret", result); + Assert.False(organization.Enabled); + } + + [Theory, BitAutoData] + public async void PurchaseOrganizationAsync_SM_Stripe_RequiresAction(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + 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.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, + 10, 10, false, taxInfo, false, 10, 10); Assert.Equal("clientSecret", result); Assert.False(organization.Enabled); @@ -287,7 +383,7 @@ public class StripePaymentServiceTests [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Paypal(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -310,7 +406,7 @@ public class StripePaymentServiceTests var braintreeGateway = sutProvider.GetDependency(); braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult); - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo); + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo); Assert.Null(result); Assert.Equal(GatewayType.Stripe, organization.Gateway); @@ -343,10 +439,70 @@ public class StripePaymentServiceTests )); } + [Theory, BitAutoData] + public async void PurchaseOrganizationAsync_SM_Paypal(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + 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.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, + 2, 10, false, taxInfo, false, 10, 10); + + 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.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var customerResult = Substitute.For>(); customerResult.IsSuccess().Returns(false); @@ -355,7 +511,25 @@ public class StripePaymentServiceTests braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo)); + () => sutProvider.Sut.PurchaseOrganizationAsync(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_SM_Paypal_FailedCreate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + 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.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, + 1, 1, false, taxInfo, false, 8, 8)); Assert.Equal("Failed to create PayPal customer record.", exception.Message); } @@ -363,7 +537,7 @@ public class StripePaymentServiceTests [Theory, BitAutoData] public async void PurchaseOrganizationAsync_PayPal_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); paymentToken = "pm_" + paymentToken; var stripeAdapter = sutProvider.GetDependency(); @@ -396,7 +570,7 @@ public class StripePaymentServiceTests braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo)); + () => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo)); Assert.Equal("Payment method was declined.", exception.Message); @@ -425,8 +599,36 @@ public class StripePaymentServiceTests }); stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { }); - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); - var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plan, 0, 0, false, taxInfo); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plans, 0, 0, false, taxInfo); + + Assert.Null(result); + } + + [Theory, BitAutoData] + public async void UpgradeFreeOrganizationAsync_SM_Success(SutProvider sutProvider, + Organization organization, TaxInfo taxInfo) + { + organization.GatewaySubscriptionId = null; + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerGetAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); + stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice + { + PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", }, + AmountDue = 0 + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { }); + + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plans, 0, 0, + false, taxInfo); Assert.Null(result); } 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 0000000000..bb31b1e600 --- /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 +