diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index 3bb25781fd..997c04f88d 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -10,6 +10,7 @@ using Bit.Core.Models.Table; using Bit.Core.Enums; using System.Linq; using Bit.Core.Repositories; +using Bit.Core.Utilities; namespace Bit.Api.Controllers { @@ -339,5 +340,88 @@ namespace Bit.Api.Controllers throw new BadRequestException(ModelState); } + + [HttpPost("premium")] + public async Task PostPremium([FromBody]PremiumRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if(user == null) + { + throw new UnauthorizedAccessException(); + } + + await _userService.SignUpPremiumAsync(user, model.PaymentToken, model.AdditionalStorageGb.GetValueOrDefault(0)); + return new ProfileResponseModel(user, null); + } + + [HttpGet("billing")] + public async Task GetBilling() + { + var user = await _userService.GetUserByPrincipalAsync(User); + if(user == null) + { + throw new UnauthorizedAccessException(); + } + + var billingInfo = await BillingHelpers.GetBillingAsync(user); + if(billingInfo == null) + { + throw new NotFoundException(); + } + + return new BillingResponseModel(billingInfo); + } + + [HttpPut("payment")] + [HttpPost("payment")] + public async Task PutPayment([FromBody]PaymentRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if(user == null) + { + throw new UnauthorizedAccessException(); + } + + await _userService.ReplacePaymentMethodAsync(user, model.PaymentToken); + } + + [HttpPut("storage")] + [HttpPost("storage")] + public async Task PutStorage([FromBody]StorageRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if(user == null) + { + throw new UnauthorizedAccessException(); + } + + await _userService.AdjustStorageAsync(user, model.StroageGbAdjustment.Value); + } + + [HttpPut("cancel-premium")] + [HttpPost("cancel-premium")] + public async Task PutCancel() + { + var user = await _userService.GetUserByPrincipalAsync(User); + if(user == null) + { + throw new UnauthorizedAccessException(); + } + + await _userService.CancelPremiumAsync(user, true); + } + + [HttpPut("reinstate-premium")] + [HttpPost("reinstate-premium")] + public async Task PutReinstate() + { + var user = await _userService.GetUserByPrincipalAsync(User); + if(user == null) + { + throw new UnauthorizedAccessException(); + } + + await _userService.ReinstatePremiumAsync(user); + } } } diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index abb5fe8555..2cdbaf0a91 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -10,6 +10,7 @@ using Bit.Core.Services; using Bit.Core; using Microsoft.AspNetCore.Identity; using Bit.Core.Models.Table; +using Bit.Core.Utilities; namespace Bit.Api.Controllers { @@ -73,7 +74,7 @@ namespace Bit.Api.Controllers throw new NotFoundException(); } - var billingInfo = await _organizationService.GetBillingAsync(organization); + var billingInfo = await BillingHelpers.GetBillingAsync(organization); if(billingInfo == null) { throw new NotFoundException(); @@ -130,7 +131,7 @@ namespace Bit.Api.Controllers [HttpPut("{id}/payment")] [HttpPost("{id}/payment")] - public async Task PutPayment(string id, [FromBody]OrganizationPaymentRequestModel model) + public async Task PutPayment(string id, [FromBody]PaymentRequestModel model) { var orgIdGuid = new Guid(id); if(!_currentContext.OrganizationOwner(orgIdGuid)) diff --git a/src/Core/Models/Api/Request/Accounts/PremiumRequestModel.cs b/src/Core/Models/Api/Request/Accounts/PremiumRequestModel.cs new file mode 100644 index 0000000000..730f0acca7 --- /dev/null +++ b/src/Core/Models/Api/Request/Accounts/PremiumRequestModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Models.Api +{ + public class PremiumRequestModel : PaymentRequestModel + { + [Range(0, 99)] + public short? AdditionalStorageGb { get; set; } + } +} diff --git a/src/Core/Models/Api/Request/Accounts/StorageRequestModel.cs b/src/Core/Models/Api/Request/Accounts/StorageRequestModel.cs new file mode 100644 index 0000000000..054d9d0fb6 --- /dev/null +++ b/src/Core/Models/Api/Request/Accounts/StorageRequestModel.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Models.Api +{ + public class StorageRequestModel : IValidatableObject + { + [Required] + public short? StroageGbAdjustment { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if(StroageGbAdjustment == 0) + { + yield return new ValidationResult("Storage adjustment cannot be 0.", + new string[] { nameof(StroageGbAdjustment) }); + } + } + } +} diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationPaymentRequestModel.cs b/src/Core/Models/Api/Request/PaymentRequestModel.cs similarity index 76% rename from src/Core/Models/Api/Request/Organizations/OrganizationPaymentRequestModel.cs rename to src/Core/Models/Api/Request/PaymentRequestModel.cs index d00c8b3445..6472c632ac 100644 --- a/src/Core/Models/Api/Request/Organizations/OrganizationPaymentRequestModel.cs +++ b/src/Core/Models/Api/Request/PaymentRequestModel.cs @@ -2,7 +2,7 @@ namespace Bit.Core.Models.Api { - public class OrganizationPaymentRequestModel + public class PaymentRequestModel { [Required] public string PaymentToken { get; set; } diff --git a/src/Core/Models/Api/Response/BillingResponseModel.cs b/src/Core/Models/Api/Response/BillingResponseModel.cs new file mode 100644 index 0000000000..c018c6c40c --- /dev/null +++ b/src/Core/Models/Api/Response/BillingResponseModel.cs @@ -0,0 +1,137 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using Bit.Core.Models.Business; +using Stripe; + +namespace Bit.Core.Models.Api +{ + public class BillingResponseModel : ResponseModel + { + public BillingResponseModel(BillingInfo billing) + : base("billing") + { + PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; + Subscription = billing.Subscription != null ? new BillingSubscription(billing.Subscription) : null; + Charges = billing.Charges.Select(c => new BillingCharge(c)); + UpcomingInvoice = billing.UpcomingInvoice != null ? new BillingInvoice(billing.UpcomingInvoice) : null; + } + + public BillingSource PaymentSource { get; set; } + public BillingSubscription Subscription { get; set; } + public BillingInvoice UpcomingInvoice { get; set; } + public IEnumerable Charges { get; set; } + } + + public class BillingSource + { + public BillingSource(Source source) + { + Type = source.Type; + + switch(source.Type) + { + case SourceType.Card: + Description = $"{source.Card.Brand}, *{source.Card.Last4}, " + + string.Format("{0}/{1}", + string.Concat(source.Card.ExpirationMonth.Length == 1 ? + "0" : string.Empty, source.Card.ExpirationMonth), + source.Card.ExpirationYear); + CardBrand = source.Card.Brand; + break; + case SourceType.BankAccount: + Description = $"{source.BankAccount.BankName}, *{source.BankAccount.Last4}"; + break; + // bitcoin/alipay? + default: + break; + } + } + + public SourceType Type { get; set; } + public string CardBrand { get; set; } + public string Description { get; set; } + } + + public class BillingSubscription + { + public BillingSubscription(StripeSubscription sub) + { + Status = sub.Status; + TrialStartDate = sub.TrialStart; + TrialEndDate = sub.TrialEnd; + EndDate = sub.CurrentPeriodEnd; + CancelledDate = sub.CanceledAt; + CancelAtEndDate = sub.CancelAtPeriodEnd; + if(sub.Items?.Data != null) + { + Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); + } + } + + public DateTime? TrialStartDate { get; set; } + public DateTime? TrialEndDate { get; set; } + public DateTime? EndDate { get; set; } + public DateTime? CancelledDate { get; set; } + public bool CancelAtEndDate { get; set; } + public string Status { get; set; } + public IEnumerable Items { get; set; } = new List(); + + public class BillingSubscriptionItem + { + public BillingSubscriptionItem(StripeSubscriptionItem item) + { + if(item.Plan != null) + { + Name = item.Plan.Name; + Amount = item.Plan.Amount / 100M; + Interval = item.Plan.Interval; + } + + Quantity = item.Quantity; + } + + public string Name { get; set; } + public decimal Amount { get; set; } + public int Quantity { get; set; } + public string Interval { get; set; } + } + } + + public class BillingInvoice + { + public BillingInvoice(StripeInvoice inv) + { + Amount = inv.AmountDue / 100M; + Date = inv.Date.Value; + } + + public decimal Amount { get; set; } + public DateTime? Date { get; set; } + } + + public class BillingCharge + { + public BillingCharge(StripeCharge charge) + { + Amount = charge.Amount / 100M; + RefundedAmount = charge.AmountRefunded / 100M; + PaymentSource = charge.Source != null ? new BillingSource(charge.Source) : null; + CreatedDate = charge.Created; + FailureMessage = charge.FailureMessage; + Refunded = charge.Refunded; + Status = charge.Status; + InvoiceId = charge.InvoiceId; + } + + public DateTime CreatedDate { get; set; } + public decimal Amount { get; set; } + public BillingSource PaymentSource { get; set; } + public string Status { get; set; } + public string FailureMessage { get; set; } + public bool Refunded { get; set; } + public bool PartiallyRefunded => !Refunded && RefundedAmount > 0; + public decimal RefundedAmount { get; set; } + public string InvoiceId { get; set; } + } +} diff --git a/src/Core/Models/Api/Response/OrganizationResponseModel.cs b/src/Core/Models/Api/Response/OrganizationResponseModel.cs index a6a8d9e8c9..71522f0979 100644 --- a/src/Core/Models/Api/Response/OrganizationResponseModel.cs +++ b/src/Core/Models/Api/Response/OrganizationResponseModel.cs @@ -3,7 +3,6 @@ using System.Linq; using Bit.Core.Models.Table; using System.Collections.Generic; using Bit.Core.Models.Business; -using Stripe; namespace Bit.Core.Models.Api { @@ -43,7 +42,7 @@ namespace Bit.Core.Models.Api public class OrganizationBillingResponseModel : OrganizationResponseModel { - public OrganizationBillingResponseModel(Organization organization, OrganizationBilling billing) + public OrganizationBillingResponseModel(Organization organization, BillingInfo billing) : base(organization, "organizationBilling") { PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; @@ -56,117 +55,5 @@ namespace Bit.Core.Models.Api public BillingSubscription Subscription { get; set; } public BillingInvoice UpcomingInvoice { get; set; } public IEnumerable Charges { get; set; } - - public class BillingSource - { - public BillingSource(Source source) - { - Type = source.Type; - - switch(source.Type) - { - case SourceType.Card: - Description = $"{source.Card.Brand}, *{source.Card.Last4}, " + - string.Format("{0}/{1}", - string.Concat(source.Card.ExpirationMonth.Length == 1 ? - "0" : string.Empty, source.Card.ExpirationMonth), - source.Card.ExpirationYear); - CardBrand = source.Card.Brand; - break; - case SourceType.BankAccount: - Description = $"{source.BankAccount.BankName}, *{source.BankAccount.Last4}"; - break; - // bitcoin/alipay? - default: - break; - } - } - - public SourceType Type { get; set; } - public string CardBrand { get; set; } - public string Description { get; set; } - } - - public class BillingSubscription - { - public BillingSubscription(StripeSubscription sub) - { - Status = sub.Status; - TrialStartDate = sub.TrialStart; - TrialEndDate = sub.TrialEnd; - EndDate = sub.CurrentPeriodEnd; - CancelledDate = sub.CanceledAt; - CancelAtEndDate = sub.CancelAtPeriodEnd; - if(sub.Items?.Data != null) - { - Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); - } - } - - public DateTime? TrialStartDate { get; set; } - public DateTime? TrialEndDate { get; set; } - public DateTime? EndDate { get; set; } - public DateTime? CancelledDate { get; set; } - public bool CancelAtEndDate { get; set; } - public string Status { get; set; } - public IEnumerable Items { get; set; } = new List(); - - public class BillingSubscriptionItem - { - public BillingSubscriptionItem(StripeSubscriptionItem item) - { - if(item.Plan != null) - { - Name = item.Plan.Name; - Amount = item.Plan.Amount / 100M; - Interval = item.Plan.Interval; - } - - Quantity = item.Quantity; - } - - public string Name { get; set; } - public decimal Amount { get; set; } - public int Quantity { get; set; } - public string Interval { get; set; } - } - } - - public class BillingInvoice - { - public BillingInvoice(StripeInvoice inv) - { - Amount = inv.AmountDue / 100M; - Date = inv.Date.Value; - } - - public decimal Amount { get; set; } - public DateTime? Date { get; set; } - } - - public class BillingCharge - { - public BillingCharge(StripeCharge charge) - { - Amount = charge.Amount / 100M; - RefundedAmount = charge.AmountRefunded / 100M; - PaymentSource = charge.Source != null ? new BillingSource(charge.Source) : null; - CreatedDate = charge.Created; - FailureMessage = charge.FailureMessage; - Refunded = charge.Refunded; - Status = charge.Status; - InvoiceId = charge.InvoiceId; - } - - public DateTime CreatedDate { get; set; } - public decimal Amount { get; set; } - public BillingSource PaymentSource { get; set; } - public string Status { get; set; } - public string FailureMessage { get; set; } - public bool Refunded { get; set; } - public bool PartiallyRefunded => !Refunded && RefundedAmount > 0; - public decimal RefundedAmount { get; set; } - public string InvoiceId { get; set; } - } } } diff --git a/src/Core/Models/Business/OrganizationBilling.cs b/src/Core/Models/Business/BillingInfo.cs similarity index 90% rename from src/Core/Models/Business/OrganizationBilling.cs rename to src/Core/Models/Business/BillingInfo.cs index 6d1e922233..310dabdc7c 100644 --- a/src/Core/Models/Business/OrganizationBilling.cs +++ b/src/Core/Models/Business/BillingInfo.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; namespace Bit.Core.Models.Business { - public class OrganizationBilling + public class BillingInfo { public Source PaymentSource { get; set; } public StripeSubscription Subscription { get; set; } diff --git a/src/Core/Models/Table/IRevisable.cs b/src/Core/Models/Table/IRevisable.cs new file mode 100644 index 0000000000..5d2b811d60 --- /dev/null +++ b/src/Core/Models/Table/IRevisable.cs @@ -0,0 +1,10 @@ +using System; + +namespace Bit.Core.Models.Table +{ + public interface IRevisable + { + DateTime CreationDate { get; } + DateTime RevisionDate { get; } + } +} diff --git a/src/Core/Models/Table/IStorable.cs b/src/Core/Models/Table/IStorable.cs new file mode 100644 index 0000000000..a65e1ef018 --- /dev/null +++ b/src/Core/Models/Table/IStorable.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Models.Table +{ + public interface IStorable + { + long? Storage { get; set; } + short? MaxStorageGb { get; set; } + long StorageBytesRemaining(); + long StorageBytesRemaining(short maxStorageGb); + } +} diff --git a/src/Core/Models/Table/IStorableSubscriber.cs b/src/Core/Models/Table/IStorableSubscriber.cs new file mode 100644 index 0000000000..87f4267a35 --- /dev/null +++ b/src/Core/Models/Table/IStorableSubscriber.cs @@ -0,0 +1,5 @@ +namespace Bit.Core.Models.Table +{ + public interface IStorableSubscriber : IStorable, ISubscriber + { } +} diff --git a/src/Core/Models/Table/ISubscriber.cs b/src/Core/Models/Table/ISubscriber.cs new file mode 100644 index 0000000000..e0db40f529 --- /dev/null +++ b/src/Core/Models/Table/ISubscriber.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Models.Table +{ + public interface ISubscriber + { + string StripeCustomerId { get; set; } + string StripeSubscriptionId { get; set; } + string BillingEmailAddress(); + string BillingName(); + } +} diff --git a/src/Core/Models/Table/Organization.cs b/src/Core/Models/Table/Organization.cs index 707bd1524a..f352259b48 100644 --- a/src/Core/Models/Table/Organization.cs +++ b/src/Core/Models/Table/Organization.cs @@ -4,7 +4,7 @@ using Bit.Core.Enums; namespace Bit.Core.Models.Table { - public class Organization : IDataObject + public class Organization : IDataObject, ISubscriber, IStorable, IStorableSubscriber, IRevisable { public Guid Id { get; set; } public string Name { get; set; } @@ -32,6 +32,16 @@ namespace Bit.Core.Models.Table } } + public string BillingEmailAddress() + { + return BillingEmail; + } + + public string BillingName() + { + return BusinessName; + } + public long StorageBytesRemaining() { if(!MaxStorageGb.HasValue) @@ -39,7 +49,12 @@ namespace Bit.Core.Models.Table return 0; } - var maxStorageBytes = MaxStorageGb.Value * 1073741824L; + return StorageBytesRemaining(MaxStorageGb.Value); + } + + public long StorageBytesRemaining(short maxStorageGb) + { + var maxStorageBytes = maxStorageGb * 1073741824L; if(!Storage.HasValue) { return maxStorageBytes; diff --git a/src/Core/Models/Table/User.cs b/src/Core/Models/Table/User.cs index fe9f1a4a11..3b798ba732 100644 --- a/src/Core/Models/Table/User.cs +++ b/src/Core/Models/Table/User.cs @@ -7,7 +7,7 @@ using System.Linq; namespace Bit.Core.Models.Table { - public class User : IDataObject + public class User : IDataObject, ISubscriber, IStorable, IStorableSubscriber, IRevisable { private Dictionary _twoFactorProviders; @@ -30,6 +30,8 @@ namespace Bit.Core.Models.Table public bool Premium { get; set; } public long? Storage { get; set; } public short? MaxStorageGb { get; set; } + public string StripeCustomerId { get; set; } + public string StripeSubscriptionId { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; @@ -38,6 +40,16 @@ namespace Bit.Core.Models.Table Id = CoreHelpers.GenerateComb(); } + public string BillingEmailAddress() + { + return Email; + } + + public string BillingName() + { + return Name; + } + public Dictionary GetTwoFactorProviders() { if(string.IsNullOrWhiteSpace(TwoFactorProviders)) @@ -110,7 +122,12 @@ namespace Bit.Core.Models.Table return 0; } - var maxStorageBytes = MaxStorageGb.Value * 1073741824L; + return StorageBytesRemaining(MaxStorageGb.Value); + } + + public long StorageBytesRemaining(short maxStorageGb) + { + var maxStorageBytes = maxStorageGb * 1073741824L; if(!Storage.HasValue) { return maxStorageBytes; diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index e26ece8e07..3ad9f84bd8 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -10,7 +10,6 @@ namespace Bit.Core.Services { public interface IOrganizationService { - Task GetBillingAsync(Organization organization); Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken); Task CancelSubscriptionAsync(Guid organizationId, bool endOfPeriod = false); Task ReinstateSubscriptionAsync(Guid organizationId); diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index ffeaffd41c..90edd5ac55 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -38,5 +38,10 @@ namespace Bit.Core.Services Task RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode); Task GenerateUserTokenAsync(User user, string tokenProvider, string purpose); Task DeleteAsync(User user); + Task SignUpPremiumAsync(User user, string paymentToken, short additionalStorageGb); + Task AdjustStorageAsync(User user, short storageAdjustmentGb); + Task ReplacePaymentMethodAsync(User user, string paymentToken); + Task CancelPremiumAsync(User user, bool endOfPeriod = false); + Task ReinstatePremiumAsync(User user); } } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 826cbae0e6..5f8c75c62f 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using Microsoft.AspNetCore.DataProtection; using Stripe; using Bit.Core.Enums; -using Bit.Core.Models.StaticStore; using Bit.Core.Models.Data; namespace Bit.Core.Services @@ -48,66 +47,6 @@ namespace Bit.Core.Services _pushNotificationService = pushNotificationService; _pushRegistrationService = pushRegistrationService; } - public async Task GetBillingAsync(Organization organization) - { - var orgBilling = new OrganizationBilling(); - var customerService = new StripeCustomerService(); - var subscriptionService = new StripeSubscriptionService(); - var chargeService = new StripeChargeService(); - var invoiceService = new StripeInvoiceService(); - - if(!string.IsNullOrWhiteSpace(organization.StripeCustomerId)) - { - var customer = await customerService.GetAsync(organization.StripeCustomerId); - if(customer != null) - { - if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null) - { - if(customer.DefaultSourceId.StartsWith("card_")) - { - orgBilling.PaymentSource = - customer.Sources.Data.FirstOrDefault(s => s.Card?.Id == customer.DefaultSourceId); - } - else if(customer.DefaultSourceId.StartsWith("ba_")) - { - orgBilling.PaymentSource = - customer.Sources.Data.FirstOrDefault(s => s.BankAccount?.Id == customer.DefaultSourceId); - } - } - - var charges = await chargeService.ListAsync(new StripeChargeListOptions - { - CustomerId = customer.Id, - Limit = 20 - }); - orgBilling.Charges = charges?.Data?.OrderByDescending(c => c.Created); - } - } - - if(!string.IsNullOrWhiteSpace(organization.StripeSubscriptionId)) - { - var sub = await subscriptionService.GetAsync(organization.StripeSubscriptionId); - if(sub != null) - { - orgBilling.Subscription = sub; - } - - if(!sub.CanceledAt.HasValue && !string.IsNullOrWhiteSpace(organization.StripeCustomerId)) - { - try - { - var upcomingInvoice = await invoiceService.UpcomingAsync(organization.StripeCustomerId); - if(upcomingInvoice != null) - { - orgBilling.UpcomingInvoice = upcomingInvoice; - } - } - catch(StripeException) { } - } - } - - return orgBilling; - } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken) { @@ -117,37 +56,11 @@ namespace Bit.Core.Services throw new NotFoundException(); } - var cardService = new StripeCardService(); - var customerService = new StripeCustomerService(); - StripeCustomer customer = null; - - if(!string.IsNullOrWhiteSpace(organization.StripeCustomerId)) + var updated = await BillingHelpers.UpdatePaymentMethodAsync(organization, paymentToken); + if(updated) { - customer = await customerService.GetAsync(organization.StripeCustomerId); - } - - if(customer == null) - { - customer = await customerService.CreateAsync(new StripeCustomerCreateOptions - { - Description = organization.BusinessName, - Email = organization.BillingEmail, - SourceToken = paymentToken - }); - - organization.StripeCustomerId = customer.Id; await _organizationRepository.ReplaceAsync(organization); } - - await cardService.CreateAsync(customer.Id, new StripeCardCreateOptions - { - SourceToken = paymentToken - }); - - if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId)) - { - await cardService.DeleteAsync(customer.Id, customer.DefaultSourceId); - } } public async Task CancelSubscriptionAsync(Guid organizationId, bool endOfPeriod = false) @@ -158,28 +71,7 @@ namespace Bit.Core.Services throw new NotFoundException(); } - if(string.IsNullOrWhiteSpace(organization.StripeSubscriptionId)) - { - throw new BadRequestException("Organization has no subscription."); - } - - var subscriptionService = new StripeSubscriptionService(); - var sub = await subscriptionService.GetAsync(organization.StripeSubscriptionId); - if(sub == null) - { - throw new BadRequestException("Organization subscription was not found."); - } - - if(sub.CanceledAt.HasValue) - { - throw new BadRequestException("Organization subscription is already canceled."); - } - - var canceledSub = await subscriptionService.CancelAsync(sub.Id, endOfPeriod); - if(!canceledSub.CanceledAt.HasValue) - { - throw new BadRequestException("Unable to cancel subscription."); - } + await BillingHelpers.CancelSubscriptionAsync(organization, endOfPeriod); } public async Task ReinstateSubscriptionAsync(Guid organizationId) @@ -190,29 +82,7 @@ namespace Bit.Core.Services throw new NotFoundException(); } - if(string.IsNullOrWhiteSpace(organization.StripeSubscriptionId)) - { - throw new BadRequestException("Organization has no subscription."); - } - - var subscriptionService = new StripeSubscriptionService(); - var sub = await subscriptionService.GetAsync(organization.StripeSubscriptionId); - if(sub == null) - { - throw new BadRequestException("Organization subscription was not found."); - } - - if(sub.Status != "active" || !sub.CanceledAt.HasValue) - { - throw new BadRequestException("Organization subscription is not marked for cancellation."); - } - - // Just touch the subscription. - var updatedSub = await subscriptionService.UpdateAsync(sub.Id, new StripeSubscriptionUpdateOptions { }); - if(updatedSub.CanceledAt.HasValue) - { - throw new BadRequestException("Unable to reinstate subscription."); - } + await BillingHelpers.ReinstateSubscriptionAsync(organization); } public async Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats) @@ -427,8 +297,6 @@ namespace Bit.Core.Services Prorate = true, SubscriptionId = sub.Id }); - - await PreviewUpcomingAndPayAsync(organization, plan); } else if(additionalSeats > 0) { @@ -438,49 +306,21 @@ namespace Bit.Core.Services Quantity = additionalSeats, Prorate = true }); - - await PreviewUpcomingAndPayAsync(organization, plan); } else if(additionalSeats == 0) { await subscriptionItemService.DeleteAsync(seatItem.Id); } + if(additionalSeats > 0) + { + await BillingHelpers.PreviewUpcomingInvoiceAndPayAsync(organization, plan.StripeSeatPlanId, 500); + } + organization.Seats = (short?)newSeatTotal; await _organizationRepository.ReplaceAsync(organization); } - private async Task PreviewUpcomingAndPayAsync(Organization org, Plan plan) - { - var invoiceService = new StripeInvoiceService(); - var upcomingPreview = await invoiceService.UpcomingAsync(org.StripeCustomerId, - new StripeUpcomingInvoiceOptions - { - SubscriptionId = org.StripeSubscriptionId - }); - - var prorationAmount = upcomingPreview.StripeInvoiceLineItems?.Data? - .TakeWhile(i => i.Plan.Id == plan.StripeSeatPlanId && i.Proration).Sum(i => i.Amount); - if(prorationAmount.GetValueOrDefault() >= 500) - { - try - { - // Owes more than $5.00 on next invoice. Invoice them and pay now instead of waiting until next month. - var invoice = await invoiceService.CreateAsync(org.StripeCustomerId, - new StripeInvoiceCreateOptions - { - SubscriptionId = org.StripeSubscriptionId - }); - - if(invoice.AmountDue > 0) - { - await invoiceService.PayAsync(invoice.Id); - } - } - catch(StripeException) { } - } - } - public async Task> SignUpAsync(OrganizationSignup signup) { var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan && !p.Disabled); @@ -620,27 +460,7 @@ namespace Bit.Core.Services } catch { - if(subscription != null) - { - await subscriptionService.CancelAsync(subscription.Id, false); - } - - if(customer != null) - { - var chargeService = new StripeChargeService(); - var charges = await chargeService.ListAsync(new StripeChargeListOptions { CustomerId = customer.Id }); - if(charges?.Data != null) - { - var refundService = new StripeRefundService(); - foreach(var charge in charges.Data.Where(c => !c.Refunded)) - { - await refundService.CreateAsync(charge.Id); - } - } - - await customerService.DeleteAsync(customer.Id); - } - + await BillingHelpers.CancelAndRecoverChargesAsync(subscription?.Id, customer?.Id); if(organization.Id != default(Guid)) { await _organizationRepository.DeleteAsync(organization); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 92c871dbb2..a939e74be7 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -16,11 +16,16 @@ using U2fLib = U2F.Core.Crypto.U2F; using U2F.Core.Models; using U2F.Core.Utils; using Bit.Core.Exceptions; +using Stripe; +using Bit.Core.Utilities; namespace Bit.Core.Services { public class UserService : UserManager, IUserService, IDisposable { + private const string PremiumPlanId = "premium-annually"; + private const string StoragePlanId = "storage-gb-annually"; + private readonly IUserRepository _userRepository; private readonly ICipherRepository _cipherRepository; private readonly IOrganizationUserRepository _organizationUserRepository; @@ -492,6 +497,109 @@ namespace Bit.Core.Services return true; } + public async Task SignUpPremiumAsync(User user, string paymentToken, short additionalStorageGb) + { + if(user.Premium) + { + throw new BadRequestException("Already a premium user."); + } + + var customerService = new StripeCustomerService(); + var customer = await customerService.CreateAsync(new StripeCustomerCreateOptions + { + Description = user.Name, + Email = user.Email, + SourceToken = paymentToken + }); + + var subCreateOptions = new StripeSubscriptionCreateOptions + { + Items = new List(), + Metadata = new Dictionary + { + ["userId"] = user.Id.ToString() + } + }; + + subCreateOptions.Items.Add(new StripeSubscriptionItemOption + { + PlanId = PremiumPlanId, + Quantity = 1 + }); + + if(additionalStorageGb > 0) + { + subCreateOptions.Items.Add(new StripeSubscriptionItemOption + { + PlanId = StoragePlanId, + Quantity = additionalStorageGb + }); + } + + StripeSubscription subscription = null; + try + { + var subscriptionService = new StripeSubscriptionService(); + subscription = await subscriptionService.CreateAsync(customer.Id, subCreateOptions); + } + catch(StripeException) + { + await customerService.DeleteAsync(customer.Id); + throw; + } + + user.Premium = true; + user.MaxStorageGb = (short)(1 + additionalStorageGb); + user.RevisionDate = DateTime.UtcNow; + user.StripeCustomerId = customer.Id; + user.StripeSubscriptionId = subscription.Id; + + try + { + await SaveUserAsync(user); + } + catch + { + await BillingHelpers.CancelAndRecoverChargesAsync(subscription.Id, customer.Id); + throw; + } + } + + public async Task AdjustStorageAsync(User user, short storageAdjustmentGb) + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if(!user.Premium) + { + throw new BadRequestException("Not a premium user."); + } + + await BillingHelpers.AdjustStorageAsync(user, storageAdjustmentGb, StoragePlanId); + await SaveUserAsync(user); + } + + public async Task ReplacePaymentMethodAsync(User user, string paymentToken) + { + var updated = await BillingHelpers.UpdatePaymentMethodAsync(user, paymentToken); + if(updated) + { + await SaveUserAsync(user); + } + } + + public async Task CancelPremiumAsync(User user, bool endOfPeriod = false) + { + await BillingHelpers.CancelSubscriptionAsync(user, endOfPeriod); + } + + public async Task ReinstatePremiumAsync(User user) + { + await BillingHelpers.ReinstateSubscriptionAsync(user); + } + private async Task UpdatePasswordHash(User user, string newPassword, bool validatePassword = true) { if(validatePassword) diff --git a/src/Core/Utilities/BillingHelpers.cs b/src/Core/Utilities/BillingHelpers.cs new file mode 100644 index 0000000000..912f531e44 --- /dev/null +++ b/src/Core/Utilities/BillingHelpers.cs @@ -0,0 +1,326 @@ +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.Table; +using Stripe; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Bit.Core.Utilities +{ + public static class BillingHelpers + { + internal static async Task CancelAndRecoverChargesAsync(string subscriptionId, string customerId) + { + if(!string.IsNullOrWhiteSpace(subscriptionId)) + { + var subscriptionService = new StripeSubscriptionService(); + await subscriptionService.CancelAsync(subscriptionId, false); + } + + if(string.IsNullOrWhiteSpace(customerId)) + { + return; + } + + var chargeService = new StripeChargeService(); + var charges = await chargeService.ListAsync(new StripeChargeListOptions { CustomerId = customerId }); + if(charges?.Data != null) + { + var refundService = new StripeRefundService(); + foreach(var charge in charges.Data.Where(c => !c.Refunded)) + { + await refundService.CreateAsync(charge.Id); + } + } + + var customerService = new StripeCustomerService(); + await customerService.DeleteAsync(customerId); + } + + public static async Task GetBillingAsync(ISubscriber subscriber) + { + var orgBilling = new BillingInfo(); + var customerService = new StripeCustomerService(); + var subscriptionService = new StripeSubscriptionService(); + var chargeService = new StripeChargeService(); + var invoiceService = new StripeInvoiceService(); + + if(!string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + var customer = await customerService.GetAsync(subscriber.StripeCustomerId); + if(customer != null) + { + if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null) + { + if(customer.DefaultSourceId.StartsWith("card_")) + { + orgBilling.PaymentSource = + customer.Sources.Data.FirstOrDefault(s => s.Card?.Id == customer.DefaultSourceId); + } + else if(customer.DefaultSourceId.StartsWith("ba_")) + { + orgBilling.PaymentSource = + customer.Sources.Data.FirstOrDefault(s => s.BankAccount?.Id == customer.DefaultSourceId); + } + } + + var charges = await chargeService.ListAsync(new StripeChargeListOptions + { + CustomerId = customer.Id, + Limit = 20 + }); + orgBilling.Charges = charges?.Data?.OrderByDescending(c => c.Created); + } + } + + if(!string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + var sub = await subscriptionService.GetAsync(subscriber.StripeSubscriptionId); + if(sub != null) + { + orgBilling.Subscription = sub; + } + + if(!sub.CanceledAt.HasValue && !string.IsNullOrWhiteSpace(subscriber.StripeCustomerId)) + { + try + { + var upcomingInvoice = await invoiceService.UpcomingAsync(subscriber.StripeCustomerId); + if(upcomingInvoice != null) + { + orgBilling.UpcomingInvoice = upcomingInvoice; + } + } + catch(StripeException) { } + } + } + + return orgBilling; + } + + internal static async Task PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, string planId, + int prorateThreshold = 500) + { + var invoiceService = new StripeInvoiceService(); + var upcomingPreview = await invoiceService.UpcomingAsync(subscriber.StripeCustomerId, + new StripeUpcomingInvoiceOptions + { + SubscriptionId = subscriber.StripeSubscriptionId + }); + + var prorationAmount = upcomingPreview.StripeInvoiceLineItems?.Data? + .TakeWhile(i => i.Plan.Id == planId && i.Proration).Sum(i => i.Amount); + if(prorationAmount.GetValueOrDefault() >= prorateThreshold) + { + try + { + // Owes more than prorateThreshold on next invoice. + // Invoice them and pay now instead of waiting until next month. + var invoice = await invoiceService.CreateAsync(subscriber.StripeCustomerId, + new StripeInvoiceCreateOptions + { + SubscriptionId = subscriber.StripeSubscriptionId + }); + + if(invoice.AmountDue > 0) + { + await invoiceService.PayAsync(invoice.Id); + } + } + catch(StripeException) { } + } + } + + internal static async Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, short storageAdjustmentGb, + string storagePlanId) + { + if(storableSubscriber == null) + { + throw new ArgumentNullException(nameof(storableSubscriber)); + } + + if(string.IsNullOrWhiteSpace(storableSubscriber.StripeCustomerId)) + { + throw new BadRequestException("No payment method found."); + } + + if(string.IsNullOrWhiteSpace(storableSubscriber.StripeSubscriptionId)) + { + throw new BadRequestException("No subscription found."); + } + + if(!storableSubscriber.MaxStorageGb.HasValue) + { + throw new BadRequestException("No access to storage."); + } + + var newStorageGb = (short)(storableSubscriber.MaxStorageGb.Value + storageAdjustmentGb); + if(newStorageGb < 1) + { + newStorageGb = 1; + } + + if(newStorageGb > 100) + { + throw new BadRequestException("Maximum storage is 100 GB."); + } + + var remainingStorage = storableSubscriber.StorageBytesRemaining(newStorageGb); + if(remainingStorage < 0) + { + throw new BadRequestException("You are currently using " + + $"{CoreHelpers.ReadableBytesSize(storableSubscriber.Storage.GetValueOrDefault(0))} of storage. " + + "Delete some stored data first."); + } + + var additionalStorage = newStorageGb - 1; + var subscriptionItemService = new StripeSubscriptionItemService(); + var subscriptionService = new StripeSubscriptionService(); + var sub = await subscriptionService.GetAsync(storableSubscriber.StripeSubscriptionId); + if(sub == null) + { + throw new BadRequestException("Subscription not found."); + } + + var seatItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == storagePlanId); + if(seatItem == null) + { + await subscriptionItemService.CreateAsync(new StripeSubscriptionItemCreateOptions + { + PlanId = storagePlanId, + Quantity = additionalStorage, + Prorate = true, + SubscriptionId = sub.Id + }); + } + else if(additionalStorage > 0) + { + await subscriptionItemService.UpdateAsync(seatItem.Id, new StripeSubscriptionItemUpdateOptions + { + PlanId = storagePlanId, + Quantity = additionalStorage, + Prorate = true + }); + } + else if(additionalStorage == 0) + { + await subscriptionItemService.DeleteAsync(storagePlanId); + } + + if(additionalStorage > 0) + { + await PreviewUpcomingInvoiceAndPayAsync(storableSubscriber, storagePlanId, 300); + } + + storableSubscriber.MaxStorageGb = newStorageGb; + } + + public static async Task UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken) + { + if(subscriber == null) + { + throw new ArgumentNullException(nameof(subscriber)); + } + + var updatedSubscriber = false; + + var cardService = new StripeCardService(); + var customerService = new StripeCustomerService(); + StripeCustomer customer = null; + + if(!string.IsNullOrWhiteSpace(subscriber.StripeCustomerId)) + { + customer = await customerService.GetAsync(subscriber.StripeCustomerId); + } + + if(customer == null) + { + customer = await customerService.CreateAsync(new StripeCustomerCreateOptions + { + Description = subscriber.BillingName(), + Email = subscriber.BillingEmailAddress(), + SourceToken = paymentToken + }); + + subscriber.StripeCustomerId = customer.Id; + updatedSubscriber = true; + } + + await cardService.CreateAsync(customer.Id, new StripeCardCreateOptions + { + SourceToken = paymentToken + }); + + if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId)) + { + await cardService.DeleteAsync(customer.Id, customer.DefaultSourceId); + } + + return updatedSubscriber; + } + + public static async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false) + { + if(subscriber == null) + { + throw new ArgumentNullException(nameof(subscriber)); + } + + if(string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + throw new BadRequestException("No subscription."); + } + + var subscriptionService = new StripeSubscriptionService(); + var sub = await subscriptionService.GetAsync(subscriber.StripeSubscriptionId); + if(sub == null) + { + throw new BadRequestException("Subscription was not found."); + } + + if(sub.CanceledAt.HasValue) + { + throw new BadRequestException("Subscription is already canceled."); + } + + var canceledSub = await subscriptionService.CancelAsync(sub.Id, endOfPeriod); + if(!canceledSub.CanceledAt.HasValue) + { + throw new BadRequestException("Unable to cancel subscription."); + } + } + + public static async Task ReinstateSubscriptionAsync(ISubscriber subscriber) + { + if(subscriber == null) + { + throw new ArgumentNullException(nameof(subscriber)); + } + + if(string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + throw new BadRequestException("No subscription."); + } + + var subscriptionService = new StripeSubscriptionService(); + var sub = await subscriptionService.GetAsync(subscriber.StripeSubscriptionId); + if(sub == null) + { + throw new BadRequestException("Subscription was not found."); + } + + if(sub.Status != "active" || !sub.CanceledAt.HasValue) + { + throw new BadRequestException("Subscription is not marked for cancellation."); + } + + // Just touch the subscription. + var updatedSub = await subscriptionService.UpdateAsync(sub.Id, new StripeSubscriptionUpdateOptions { }); + if(updatedSub.CanceledAt.HasValue) + { + throw new BadRequestException("Unable to reinstate subscription."); + } + } + } +} diff --git a/src/Sql/dbo/Stored Procedures/User_Create.sql b/src/Sql/dbo/Stored Procedures/User_Create.sql index 43fe33b9a1..ba1b8f44d3 100644 --- a/src/Sql/dbo/Stored Procedures/User_Create.sql +++ b/src/Sql/dbo/Stored Procedures/User_Create.sql @@ -18,6 +18,8 @@ @Premium BIT, @Storage BIGINT, @MaxStorageGb SMALLINT, + @StripeCustomerId VARCHAR(50), + @StripeSubscriptionId VARCHAR(50), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -45,6 +47,8 @@ BEGIN [Premium], [Storage], [MaxStorageGb], + [StripeCustomerId], + [StripeSubscriptionId], [CreationDate], [RevisionDate] ) @@ -69,6 +73,8 @@ BEGIN @Premium, @Storage, @MaxStorageGb, + @StripeCustomerId, + @StripeSubscriptionId, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/Stored Procedures/User_Update.sql b/src/Sql/dbo/Stored Procedures/User_Update.sql index c4c36c64d7..55d335cb6c 100644 --- a/src/Sql/dbo/Stored Procedures/User_Update.sql +++ b/src/Sql/dbo/Stored Procedures/User_Update.sql @@ -18,6 +18,8 @@ @Premium BIT, @Storage BIGINT, @MaxStorageGb SMALLINT, + @StripeCustomerId VARCHAR(50), + @StripeSubscriptionId VARCHAR(50), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -45,6 +47,8 @@ BEGIN [Premium] = @Premium, [Storage] = @Storage, [MaxStorageGb] = @MaxStorageGb, + [StripeCustomerId] = @StripeCustomerId, + [StripeSubscriptionId] = @StripeSubscriptionId, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/Tables/User.sql b/src/Sql/dbo/Tables/User.sql index 178adb4a89..2c8f466d14 100644 --- a/src/Sql/dbo/Tables/User.sql +++ b/src/Sql/dbo/Tables/User.sql @@ -18,6 +18,8 @@ [Premium] BIT NOT NULL, [Storage] BIGINT NULL, [MaxStorageGb] SMALLINT NULL, + [StripeCustomerId] VARCHAR (50) NULL, + [StripeSubscriptionId] VARCHAR (50) NULL, [CreationDate] DATETIME2 (7) NOT NULL, [RevisionDate] DATETIME2 (7) NOT NULL, CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED ([Id] ASC)