From 5bfed59f9c8372f5bbea8ff815d8576a95e17b83 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 21 Mar 2019 21:36:03 -0400 Subject: [PATCH] upgrade org api --- .../Controllers/OrganizationsController.cs | 2 +- .../OrganizationUpgradeRequestModel.cs | 18 ++ .../Models/Business/OrganizationSignup.cs | 9 +- .../Models/Business/OrganizationUpgrade.cs | 13 ++ src/Core/Services/IOrganizationService.cs | 2 +- src/Core/Services/IPaymentService.cs | 2 + .../Implementations/OrganizationService.cs | 199 ++++++++---------- .../Implementations/StripePaymentService.cs | 140 ++++++++++-- 8 files changed, 252 insertions(+), 133 deletions(-) create mode 100644 src/Core/Models/Business/OrganizationUpgrade.cs diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 66206025b0..add6c9e16c 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -223,7 +223,7 @@ namespace Bit.Api.Controllers throw new NotFoundException(); } - await _organizationService.UpgradePlanAsync(orgIdGuid, model.PlanType, model.AdditionalSeats); + await _organizationService.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()); } [HttpPost("{id}/seat")] diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationUpgradeRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationUpgradeRequestModel.cs index 63346e582d..d1d95c3a95 100644 --- a/src/Core/Models/Api/Request/Organizations/OrganizationUpgradeRequestModel.cs +++ b/src/Core/Models/Api/Request/Organizations/OrganizationUpgradeRequestModel.cs @@ -1,12 +1,30 @@ using Bit.Core.Enums; +using Bit.Core.Models.Business; using System.ComponentModel.DataAnnotations; namespace Bit.Core.Models.Api { public class OrganizationUpgradeRequestModel { + [StringLength(50)] + public string BusinessName { get; set; } public PlanType PlanType { get; set; } [Range(0, double.MaxValue)] public short AdditionalSeats { get; set; } + [Range(0, 99)] + public short? AdditionalStorageGb { get; set; } + public bool PremiumAccessAddon { get; set; } + + public OrganizationUpgrade ToOrganizationUpgrade() + { + return new OrganizationUpgrade + { + AdditionalSeats = AdditionalSeats, + AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(), + BusinessName = BusinessName, + Plan = PlanType, + PremiumAccessAddon = PremiumAccessAddon + }; + } } } diff --git a/src/Core/Models/Business/OrganizationSignup.cs b/src/Core/Models/Business/OrganizationSignup.cs index d930296577..5e00c1f24f 100644 --- a/src/Core/Models/Business/OrganizationSignup.cs +++ b/src/Core/Models/Business/OrganizationSignup.cs @@ -3,19 +3,14 @@ using Bit.Core.Models.Table; namespace Bit.Core.Models.Business { - public class OrganizationSignup + public class OrganizationSignup : OrganizationUpgrade { public string Name { get; set; } - public string BusinessName { get; set; } public string BillingEmail { get; set; } public User Owner { get; set; } public string OwnerKey { get; set; } - public PlanType Plan { get; set; } - public short AdditionalSeats { get; set; } - public short AdditionalStorageGb { get; set; } - public bool PremiumAccessAddon { get; set; } + public string CollectionName { get; set; } public PaymentMethodType? PaymentMethodType { get; set; } public string PaymentToken { get; set; } - public string CollectionName { get; set; } } } diff --git a/src/Core/Models/Business/OrganizationUpgrade.cs b/src/Core/Models/Business/OrganizationUpgrade.cs new file mode 100644 index 0000000000..07e7b98f96 --- /dev/null +++ b/src/Core/Models/Business/OrganizationUpgrade.cs @@ -0,0 +1,13 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Models.Business +{ + public class OrganizationUpgrade + { + public string BusinessName { get; set; } + public PlanType Plan { get; set; } + public short AdditionalSeats { get; set; } + public short AdditionalStorageGb { get; set; } + public bool PremiumAccessAddon { get; set; } + } +} diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 61e25dc31a..000c438c90 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -13,7 +13,7 @@ namespace Bit.Core.Services Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, PaymentMethodType paymentMethodType); Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null); Task ReinstateSubscriptionAsync(Guid organizationId); - Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats); + Task UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade); Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 4b6b7ca541..a07b6bba46 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -10,6 +10,8 @@ namespace Bit.Core.Services Task CancelAndRecoverChargesAsync(ISubscriber subscriber); Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, short additionalSeats, bool premiumAccessAddon); + Task UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan, + short additionalStorageGb, short additionalSeats, bool premiumAccessAddon); Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index b90b9f67a5..00539a6277 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -117,7 +117,7 @@ namespace Bit.Core.Services await _paymentService.ReinstateSubscriptionAsync(organization); } - public async Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats) + public async Task UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade) { var organization = await GetOrgById(organizationId); if(organization == null) @@ -127,7 +127,7 @@ namespace Bit.Core.Services if(string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { - throw new BadRequestException("No payment method found."); + throw new BadRequestException("Your account has no payment method available."); } var existingPlan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); @@ -136,7 +136,7 @@ namespace Bit.Core.Services throw new BadRequestException("Existing plan not found."); } - var newPlan = StaticStore.Plans.FirstOrDefault(p => p.Type == plan && !p.Disabled); + var newPlan = StaticStore.Plans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); if(newPlan == null) { throw new BadRequestException("Plan not found."); @@ -152,31 +152,27 @@ namespace Bit.Core.Services throw new BadRequestException("You cannot upgrade to this plan."); } - if(!newPlan.CanBuyAdditionalSeats && additionalSeats > 0) + if(existingPlan.Type != PlanType.Free) { - throw new BadRequestException("Plan does not allow additional seats."); + throw new BadRequestException("You can only upgrade from the free plan. Contact support."); } - if(newPlan.CanBuyAdditionalSeats && newPlan.MaxAdditionalSeats.HasValue && - additionalSeats > newPlan.MaxAdditionalSeats.Value) - { - throw new BadRequestException($"Selected plan allows a maximum of " + - $"{newPlan.MaxAdditionalSeats.Value} additional seats."); - } + ValidateOrganizationUpgradeParameters(newPlan, upgrade); - var newPlanSeats = (short)(newPlan.BaseSeats + (newPlan.CanBuyAdditionalSeats ? additionalSeats : 0)); + var newPlanSeats = (short)(newPlan.BaseSeats + + (newPlan.CanBuyAdditionalSeats ? upgrade.AdditionalSeats : 0)); if(!organization.Seats.HasValue || organization.Seats.Value > newPlanSeats) { var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organization.Id); if(userCount > newPlanSeats) { - throw new BadRequestException($"Your organization currently has {userCount} seats filled. Your new plan " + - $"only has ({newPlanSeats}) seats. Remove some users."); + throw new BadRequestException($"Your organization currently has {userCount} seats filled. " + + $"Your new plan only has ({newPlanSeats}) seats. Remove some users."); } } - if(newPlan.MaxCollections.HasValue && - (!organization.MaxCollections.HasValue || organization.MaxCollections.Value > newPlan.MaxCollections.Value)) + 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) @@ -187,72 +183,47 @@ namespace Bit.Core.Services } } - // TODO: Groups? + if(!newPlan.UseGroups && 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."); + } + } + + // TODO: Check storage? - var subscriptionService = new Stripe.SubscriptionService(); if(string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { - // They must have been on a free plan. Create new sub. - var subCreateOptions = new SubscriptionCreateOptions - { - CustomerId = organization.GatewayCustomerId, - TrialPeriodDays = newPlan.TrialPeriodDays, - Items = new List(), - Metadata = new Dictionary { - { "organizationId", organization.Id.ToString() } - } - }; - - if(newPlan.StripePlanId != null) - { - subCreateOptions.Items.Add(new SubscriptionItemOption - { - PlanId = newPlan.StripePlanId, - Quantity = 1 - }); - } - - if(additionalSeats > 0 && newPlan.StripeSeatPlanId != null) - { - subCreateOptions.Items.Add(new SubscriptionItemOption - { - PlanId = newPlan.StripeSeatPlanId, - Quantity = additionalSeats - }); - } - - await subscriptionService.CreateAsync(subCreateOptions); + await _paymentService.UpgradeFreeOrganizationAsync(organization, newPlan, + upgrade.AdditionalStorageGb, upgrade.AdditionalSeats, upgrade.PremiumAccessAddon); } else { - // Update existing sub. - var subUpdateOptions = new SubscriptionUpdateOptions - { - Items = new List() - }; - - if(newPlan.StripePlanId != null) - { - subUpdateOptions.Items.Add(new SubscriptionItemUpdateOption - { - PlanId = newPlan.StripePlanId, - Quantity = 1 - }); - } - - if(additionalSeats > 0 && newPlan.StripeSeatPlanId != null) - { - subUpdateOptions.Items.Add(new SubscriptionItemUpdateOption - { - PlanId = newPlan.StripeSeatPlanId, - Quantity = additionalSeats - }); - } - - await subscriptionService.UpdateAsync(organization.GatewaySubscriptionId, subUpdateOptions); + // TODO: Update existing sub + throw new BadRequestException("You can only upgrade from the free plan. Contact support."); } - // TODO: Update organization + organization.BusinessName = upgrade.BusinessName; + organization.PlanType = newPlan.Type; + organization.Seats = (short)(newPlan.BaseSeats + upgrade.AdditionalSeats); + organization.MaxCollections = newPlan.MaxCollections; + organization.MaxStorageGb = !newPlan.MaxStorageGb.HasValue ? + (short?)null : (short)(newPlan.MaxStorageGb.Value + upgrade.AdditionalStorageGb); + organization.UseGroups = newPlan.UseGroups; + organization.UseDirectory = newPlan.UseDirectory; + organization.UseEvents = newPlan.UseEvents; + organization.UseTotp = newPlan.UseTotp; + organization.Use2fa = newPlan.Use2fa; + organization.UseApi = newPlan.UseApi; + organization.SelfHost = newPlan.SelfHost; + organization.UsersGetPremium = newPlan.UsersGetPremium || upgrade.PremiumAccessAddon; + organization.Plan = newPlan.Name; + organization.Enabled = true; + organization.RevisionDate = DateTime.UtcNow; + await ReplaceAndUpdateCache(organization); } public async Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb) @@ -459,42 +430,7 @@ namespace Bit.Core.Services throw new BadRequestException("Plan not found."); } - if(!plan.MaxStorageGb.HasValue && signup.AdditionalStorageGb > 0) - { - throw new BadRequestException("Plan does not allow additional storage."); - } - - if(signup.AdditionalStorageGb < 0) - { - throw new BadRequestException("You can't subtract storage!"); - } - - if(!plan.CanBuyPremiumAccessAddon && signup.PremiumAccessAddon) - { - throw new BadRequestException("This plan does not allow you to buy the premium access addon."); - } - - if(plan.BaseSeats + signup.AdditionalSeats <= 0) - { - throw new BadRequestException("You do not have any seats!"); - } - - if(signup.AdditionalSeats < 0) - { - throw new BadRequestException("You can't subtract seats!"); - } - - if(!plan.CanBuyAdditionalSeats && signup.AdditionalSeats > 0) - { - throw new BadRequestException("Plan does not allow additional users."); - } - - if(plan.CanBuyAdditionalSeats && plan.MaxAdditionalSeats.HasValue && - signup.AdditionalSeats > plan.MaxAdditionalSeats.Value) - { - throw new BadRequestException($"Selected plan allows a maximum of " + - $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); - } + ValidateOrganizationUpgradeParameters(plan, signup); var organization = new Organization { @@ -644,7 +580,8 @@ namespace Bit.Core.Services // push var deviceIds = await GetUserDeviceIdsAsync(orgUser.UserId.Value); - await _pushRegistrationService.AddUserRegistrationOrganizationAsync(deviceIds, organization.Id.ToString()); + await _pushRegistrationService.AddUserRegistrationOrganizationAsync(deviceIds, + organization.Id.ToString()); await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); return new Tuple(organization, orgUser); @@ -1395,5 +1332,45 @@ namespace Bit.Core.Services { return await _organizationRepository.GetByIdAsync(id); } + + private void ValidateOrganizationUpgradeParameters(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade) + { + if(!plan.MaxStorageGb.HasValue && 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.CanBuyPremiumAccessAddon && upgrade.PremiumAccessAddon) + { + 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.CanBuyAdditionalSeats && upgrade.AdditionalSeats > 0) + { + throw new BadRequestException("Plan does not allow additional users."); + } + + if(plan.CanBuyAdditionalSeats && 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/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 0dcb7bc5a7..80d354c117 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -162,6 +162,95 @@ namespace Bit.Core.Services org.ExpirationDate = subscription.CurrentPeriodEnd; } + public async Task UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan, + short additionalStorageGb, short additionalSeats, bool premiumAccessAddon) + { + if(!string.IsNullOrWhiteSpace(org.GatewaySubscriptionId)) + { + throw new BadRequestException("Organization already has a subscription."); + } + + var customerService = new CustomerService(); + customerService.ExpandDefaultSource = true; + var customer = await customerService.GetAsync(org.GatewayCustomerId); + if(customer == null) + { + throw new GatewayException("Could not find customer payment profile."); + } + + var subCreateOptions = new SubscriptionCreateOptions + { + CustomerId = customer.Id, + Items = new List(), + Metadata = new Dictionary + { + [org.GatewayIdField()] = org.Id.ToString() + } + }; + + if(plan.StripePlanId != null) + { + subCreateOptions.Items.Add(new SubscriptionItemOption + { + PlanId = plan.StripePlanId, + Quantity = 1 + }); + } + + if(additionalSeats > 0 && plan.StripeSeatPlanId != null) + { + subCreateOptions.Items.Add(new SubscriptionItemOption + { + PlanId = plan.StripeSeatPlanId, + Quantity = additionalSeats + }); + } + + if(additionalStorageGb > 0) + { + subCreateOptions.Items.Add(new SubscriptionItemOption + { + PlanId = plan.StripeStoragePlanId, + Quantity = additionalStorageGb + }); + } + + if(premiumAccessAddon && plan.StripePremiumAccessPlanId != null) + { + subCreateOptions.Items.Add(new SubscriptionItemOption + { + PlanId = plan.StripePremiumAccessPlanId, + Quantity = 1 + }); + } + + var stripePaymentMethod = false; + var paymentMethodType = PaymentMethodType.Credit; + var hasBtCustomerId = customer.Metadata.ContainsKey("btCustomerId"); + if(hasBtCustomerId) + { + paymentMethodType = PaymentMethodType.PayPal; + } + if(!hasBtCustomerId && customer.DefaultSource != null) + { + if(customer.DefaultSource is Card || customer.DefaultSource is SourceCard) + { + paymentMethodType = PaymentMethodType.Card; + stripePaymentMethod = true; + } + else if(customer.DefaultSource is BankAccount || customer.DefaultSource is SourceAchDebit) + { + paymentMethodType = PaymentMethodType.BankAccount; + stripePaymentMethod = true; + } + } + + var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, + stripePaymentMethod, paymentMethodType, subCreateOptions, null); + org.GatewaySubscriptionId = subscription.Id; + org.ExpirationDate = subscription.CurrentPeriodEnd; + } + public async Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb) { @@ -179,13 +268,9 @@ namespace Bit.Core.Services throw new GatewayException("Bank account payment method is not supported at this time."); } - var invoiceService = new InvoiceService(); var customerService = new CustomerService(); - var createdStripeCustomer = false; - var addedCreditToStripeCustomer = false; Customer customer = null; - Braintree.Transaction braintreeTransaction = null; Braintree.Customer braintreeCustomer = null; var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card || paymentMethodType == PaymentMethodType.BankAccount || paymentMethodType == PaymentMethodType.Credit; @@ -283,6 +368,25 @@ namespace Bit.Core.Services }); } + var subscription = await ChargeForNewSubscriptionAsync(user, customer, createdStripeCustomer, + stripePaymentMethod, paymentMethodType, subCreateOptions, braintreeCustomer); + + user.Gateway = GatewayType.Stripe; + user.GatewayCustomerId = customer.Id; + user.GatewaySubscriptionId = subscription.Id; + user.Premium = true; + user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + } + + private async Task ChargeForNewSubscriptionAsync(ISubscriber subcriber, Customer customer, + bool createdStripeCustomer, bool stripePaymentMethod, PaymentMethodType paymentMethodType, + SubscriptionCreateOptions subCreateOptions, Braintree.Customer braintreeCustomer) + { + var addedCreditToStripeCustomer = false; + Braintree.Transaction braintreeTransaction = null; + var invoiceService = new InvoiceService(); + var customerService = new CustomerService(); + var subInvoiceMetadata = new Dictionary(); Subscription subscription = null; try @@ -312,12 +416,12 @@ namespace Bit.Core.Services SubmitForSettlement = true, PayPal = new Braintree.TransactionOptionsPayPalRequest { - CustomField = $"{user.BraintreeIdField()}:{user.Id}" + CustomField = $"{subcriber.BraintreeIdField()}:{subcriber.Id}" } }, CustomFields = new Dictionary { - [user.BraintreeIdField()] = user.Id.ToString() + [subcriber.BraintreeIdField()] = subcriber.Id.ToString() } }); @@ -377,6 +481,8 @@ namespace Bit.Core.Services Metadata = subInvoiceMetadata }); } + + return subscription; } catch(Exception e) { @@ -386,7 +492,7 @@ namespace Bit.Core.Services { await customerService.DeleteAsync(customer.Id); } - else if(addedCreditToStripeCustomer) + else if(addedCreditToStripeCustomer || customer.AccountBalance < 0) { await customerService.UpdateAsync(customer.Id, new CustomerUpdateOptions { @@ -402,14 +508,15 @@ namespace Bit.Core.Services { await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); } + + if(e is StripeException strEx && + (strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false)) + { + throw new GatewayException("Bank account is not yet verified."); + } + throw e; } - - user.Gateway = GatewayType.Stripe; - user.GatewayCustomerId = customer.Id; - user.GatewaySubscriptionId = subscription.Id; - user.Premium = true; - user.PremiumExpirationDate = subscription.CurrentPeriodEnd; } private List ToInvoiceSubscriptionItemOptions( @@ -721,6 +828,13 @@ namespace Bit.Core.Services await invoiceItemService.DeleteAsync(ii.Id); } } + + if(e is StripeException strEx && + (strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false)) + { + throw new GatewayException("Bank account is not yet verified."); + } + throw e; } }