diff --git a/src/Core/Enums/PlanType.cs b/src/Core/Enums/PlanType.cs index 657352dc21..48c6ba39e8 100644 --- a/src/Core/Enums/PlanType.cs +++ b/src/Core/Enums/PlanType.cs @@ -3,9 +3,11 @@ public enum PlanType : byte { Free = 0, - Personal = 1, - Teams = 2, - Enterprise = 3, - Custom = 4 + PersonalAnnually = 1, + TeamsMonthly = 2, + TeamsAnnually = 3, + EnterpriseMonthly = 4, + EnterpriseAnnually = 5, + Custom = 6 } } diff --git a/src/Core/Models/Business/OrganizationChangePlan.cs b/src/Core/Models/Business/OrganizationChangePlan.cs new file mode 100644 index 0000000000..5d80b8cf29 --- /dev/null +++ b/src/Core/Models/Business/OrganizationChangePlan.cs @@ -0,0 +1,13 @@ +using Bit.Core.Enums; +using System; + +namespace Bit.Core.Models.Business +{ + public class OrganizationChangePlan + { + public Guid OrganizationId { get; set; } + public PlanType PlanType { get; set; } + public short AdditionalUsers { get; set; } + public bool Monthly { get; set; } + } +} diff --git a/src/Core/Models/StaticStore/Plan.cs b/src/Core/Models/StaticStore/Plan.cs index e928986ef3..8f9c83968c 100644 --- a/src/Core/Models/StaticStore/Plan.cs +++ b/src/Core/Models/StaticStore/Plan.cs @@ -1,25 +1,20 @@ using Bit.Core.Enums; -using System; namespace Bit.Core.Models.StaticStore { public class Plan { public string Name { get; set; } - public string StripeAnnualPlanId { get; set; } - public string StripeAnnualUserPlanId { get; set; } - public string StripeMonthlyPlanId { get; set; } - public string StripeMonthlyUserPlanId { get; set; } + public string StripePlanId { get; set; } + public string StripeUserPlanId { get; set; } public PlanType Type { get; set; } public short BaseUsers { get; set; } public bool CanBuyAdditionalUsers { get; set; } public short? MaxAdditionalUsers { get; set; } - public bool CanMonthly { get; set; } - public decimal BaseMonthlyPrice { get; set; } - public decimal UserMonthlyPrice { get; set; } - public decimal BaseAnnualPrice { get; set; } - public decimal UserAnnualPrice { get; set; } + public decimal BasePrice { get; set; } + public decimal UserPrice { get; set; } public short? MaxSubvaults { get; set; } + public int UpgradeSortOrder { get; set; } public bool Disabled { get; set; } } } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 4bf5c3932c..d72c8ad37c 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -160,6 +160,201 @@ namespace Bit.Core.Services } } + public async Task UpgradePlanAsync(OrganizationChangePlan model) + { + var organization = await _organizationRepository.GetByIdAsync(model.OrganizationId); + if(organization == null) + { + throw new NotFoundException(); + } + + if(string.IsNullOrWhiteSpace(organization.StripeCustomerId)) + { + throw new BadRequestException("No payment method found."); + } + + var existingPlan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); + if(existingPlan == null) + { + throw new BadRequestException("Existing plan not found."); + } + + var newPlan = StaticStore.Plans.FirstOrDefault(p => p.Type == model.PlanType && !p.Disabled); + if(newPlan == null) + { + 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(!newPlan.CanBuyAdditionalUsers && model.AdditionalUsers > 0) + { + throw new BadRequestException("Plan does not allow additional users."); + } + + if(newPlan.CanBuyAdditionalUsers && newPlan.MaxAdditionalUsers.HasValue && + model.AdditionalUsers > newPlan.MaxAdditionalUsers.Value) + { + throw new BadRequestException($"Selected plan allows a maximum of " + + $"{newPlan.MaxAdditionalUsers.Value} additional users."); + } + + var newPlanMaxUsers = (short)(newPlan.BaseUsers + (newPlan.CanBuyAdditionalUsers ? model.AdditionalUsers : 0)); + if(!organization.MaxUsers.HasValue || organization.MaxUsers.Value > newPlanMaxUsers) + { + var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organization.Id); + if(userCount >= newPlanMaxUsers) + { + throw new BadRequestException($"Your organization currently has {userCount} users. Your new plan " + + $"allows for a maximum of ({newPlanMaxUsers}) users. Remove some users."); + } + } + + if(newPlan.MaxSubvaults.HasValue && + (!organization.MaxSubvaults.HasValue || organization.MaxSubvaults.Value > newPlan.MaxSubvaults.Value)) + { + var subvaultCount = await _subvaultRepository.GetCountByOrganizationIdAsync(organization.Id); + if(subvaultCount > newPlan.MaxSubvaults.Value) + { + throw new BadRequestException($"Your organization currently has {subvaultCount} subvaults. " + + $"Your new plan allows for a maximum of ({newPlan.MaxSubvaults.Value}) users. Remove some subvaults."); + } + } + + var subscriptionService = new StripeSubscriptionService(); + if(string.IsNullOrWhiteSpace(organization.StripeSubscriptionId)) + { + // They must have been on a free plan. Create new sub. + var subCreateOptions = new StripeSubscriptionCreateOptions + { + Items = new List + { + new StripeSubscriptionItemOption + { + PlanId = newPlan.StripePlanId, + Quantity = 1 + } + } + }; + + if(model.AdditionalUsers > 0) + { + subCreateOptions.Items.Add(new StripeSubscriptionItemOption + { + PlanId = newPlan.StripeUserPlanId, + Quantity = model.AdditionalUsers + }); + } + + await subscriptionService.CreateAsync(organization.StripeCustomerId, subCreateOptions); + } + else + { + // Update existing sub. + var subUpdateOptions = new StripeSubscriptionUpdateOptions + { + Items = new List + { + new StripeSubscriptionItemUpdateOption + { + PlanId = newPlan.StripePlanId, + Quantity = 1 + } + } + }; + + if(model.AdditionalUsers > 0) + { + subUpdateOptions.Items.Add(new StripeSubscriptionItemUpdateOption + { + PlanId = newPlan.StripeUserPlanId, + Quantity = model.AdditionalUsers + }); + } + + await subscriptionService.UpdateAsync(organization.StripeSubscriptionId, subUpdateOptions); + } + } + + public async Task AdjustAdditionalUsersAsync(Guid organizationId, short additionalUsers) + { + var organization = await _organizationRepository.GetByIdAsync(organizationId); + if(organization == null) + { + throw new NotFoundException(); + } + + if(string.IsNullOrWhiteSpace(organization.StripeCustomerId)) + { + throw new BadRequestException("No payment method found."); + } + + if(!string.IsNullOrWhiteSpace(organization.StripeSubscriptionId)) + { + throw new BadRequestException("No subscription found."); + } + + var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); + if(plan == null) + { + throw new BadRequestException("Existing plan not found."); + } + + if(!plan.CanBuyAdditionalUsers) + { + throw new BadRequestException("Plan does not allow additional users."); + } + + if(plan.MaxAdditionalUsers.HasValue && additionalUsers > plan.MaxAdditionalUsers.Value) + { + throw new BadRequestException($"Organization plan allows a maximum of " + + $"{plan.MaxAdditionalUsers.Value} additional users."); + } + + var planNewMaxUsers = (short)(plan.BaseUsers + additionalUsers); + if(!organization.MaxUsers.HasValue || organization.MaxUsers.Value > planNewMaxUsers) + { + var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organization.Id); + if(userCount >= planNewMaxUsers) + { + throw new BadRequestException($"Your organization currently has {userCount} users. Your new plan " + + $"allows for a maximum of ({planNewMaxUsers}) users. Remove some users."); + } + } + + var subscriptionService = new StripeSubscriptionService(); + var subUpdateOptions = new StripeSubscriptionUpdateOptions + { + Items = new List + { + new StripeSubscriptionItemUpdateOption + { + PlanId = plan.StripePlanId, + Quantity = 1 + } + } + }; + + if(additionalUsers > 0) + { + subUpdateOptions.Items.Add(new StripeSubscriptionItemUpdateOption + { + PlanId = plan.StripeUserPlanId, + Quantity = additionalUsers + }); + } + + await subscriptionService.UpdateAsync(organization.StripeSubscriptionId, subUpdateOptions); + } + public async Task> SignUpAsync(OrganizationSignup signup) { var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan && !p.Disabled); @@ -173,6 +368,11 @@ namespace Bit.Core.Services StripeCustomer customer = null; StripeSubscription subscription = null; + if(!plan.CanBuyAdditionalUsers && signup.AdditionalUsers > 0) + { + throw new BadRequestException("Plan does not allow additional users."); + } + if(plan.CanBuyAdditionalUsers && plan.MaxAdditionalUsers.HasValue && signup.AdditionalUsers > plan.MaxAdditionalUsers.Value) { @@ -204,17 +404,17 @@ namespace Bit.Core.Services { new StripeSubscriptionItemOption { - PlanId = plan.CanMonthly && signup.Monthly ? plan.StripeMonthlyPlanId : plan.StripeAnnualPlanId, + PlanId = plan.StripePlanId, Quantity = 1 } } }; - if(plan.CanBuyAdditionalUsers && signup.AdditionalUsers > 0) + if(signup.AdditionalUsers > 0) { subCreateOptions.Items.Add(new StripeSubscriptionItemOption { - PlanId = plan.CanMonthly && signup.Monthly ? plan.StripeMonthlyUserPlanId : plan.StripeAnnualUserPlanId, + PlanId = plan.StripeUserPlanId, Quantity = signup.AdditionalUsers }); } @@ -228,7 +428,7 @@ namespace Bit.Core.Services BillingEmail = signup.BillingEmail, BusinessName = signup.BusinessName, PlanType = plan.Type, - MaxUsers = (short)(plan.BaseUsers + (plan.CanBuyAdditionalUsers ? signup.AdditionalUsers : 0)), + MaxUsers = (short)(plan.BaseUsers + signup.AdditionalUsers), MaxSubvaults = plan.MaxSubvaults, Plan = plan.Name, StripeCustomerId = customer?.Id, diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index aab327a497..becfe8d8a3 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -97,36 +97,45 @@ namespace Bit.Core.Utilities BaseUsers = 2, CanBuyAdditionalUsers = false, MaxSubvaults = 2, - Name = "Free" + Name = "Free", + UpgradeSortOrder = -1 // Always the lowest plan, cannot be upgraded to }, new Plan { - Type = PlanType.Personal, + Type = PlanType.PersonalAnnually, BaseUsers = 5, - BaseAnnualPrice = 12, - UserAnnualPrice = 12, + BasePrice = 12, + UserPrice = 12, CanBuyAdditionalUsers = true, MaxAdditionalUsers = 5, - CanMonthly = false, Name = "Personal", - StripeAnnualPlanId = "personal-annual", - StripeAnnualUserPlanId = "personal-user-annual" + StripePlanId = "personal-annual", + StripeUserPlanId = "personal-user-annual", + UpgradeSortOrder = 1 }, new Plan { - Type = PlanType.Teams, + Type = PlanType.TeamsMonthly, BaseUsers = 5, - BaseAnnualPrice = 60, - UserAnnualPrice = 24, - BaseMonthlyPrice = 8, - UserMonthlyPrice = 2.5M, + BasePrice = 8, + UserPrice = 2.5M, CanBuyAdditionalUsers = true, - CanMonthly = true, - Name = "Teams", - StripeAnnualPlanId = "teams-annual", - StripeAnnualUserPlanId = "teams-user-annual", - StripeMonthlyPlanId = "teams-monthly", - StripeMonthlyUserPlanId = "teams-user-monthly" + Name = "Teams (Monthly)", + StripePlanId = "teams-monthly", + StripeUserPlanId = "teams-user-monthly", + UpgradeSortOrder = 2 + }, + new Plan + { + Type = PlanType.TeamsAnnually, + BaseUsers = 5, + BasePrice = 60, + UserPrice = 24, + CanBuyAdditionalUsers = true, + Name = "Teams (Annually)", + StripePlanId = "teams-annual", + StripeUserPlanId = "teams-user-annual", + UpgradeSortOrder = 2 } };