diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index 28d5636987..e8049a6c87 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -11,6 +11,7 @@ using Bit.Core.Enums; using System.Linq; using Bit.Core.Repositories; using Bit.Core.Utilities; +using Bit.Core; namespace Bit.Api.Controllers { @@ -22,17 +23,20 @@ namespace Bit.Api.Controllers private readonly ICipherService _cipherService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly UserManager _userManager; + private readonly GlobalSettings _globalSettings; public AccountsController( IUserService userService, ICipherService cipherService, IOrganizationUserRepository organizationUserRepository, - UserManager userManager) + UserManager userManager, + GlobalSettings globalSettings) { _userService = userService; _cipherService = cipherService; _organizationUserRepository = organizationUserRepository; _userManager = userManager; + _globalSettings = globalSettings; } [HttpPost("register")] @@ -363,7 +367,8 @@ namespace Bit.Api.Controllers throw new UnauthorizedAccessException(); } - var billingInfo = await BillingHelpers.GetBillingAsync(user); + var paymentService = user.GetPaymentService(_globalSettings); + var billingInfo = await paymentService.GetBillingAsync(user); if(billingInfo == null) { throw new NotFoundException(); diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 368b6471b4..634d0e3ed7 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -74,7 +74,8 @@ namespace Bit.Api.Controllers throw new NotFoundException(); } - var billingInfo = await BillingHelpers.GetBillingAsync(organization); + var paymentService = new StripePaymentService(); + var billingInfo = await paymentService.GetBillingAsync(organization); if(billingInfo == null) { throw new NotFoundException(); @@ -264,10 +265,10 @@ namespace Bit.Api.Controllers var userId = _userService.GetProperUserId(User); await _organizationService.ImportAsync( - orgIdGuid, - userId.Value, + orgIdGuid, + userId.Value, model.Groups.Select(g => g.ToImportedGroup(orgIdGuid)), - model.Users.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), + model.Users.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), model.Users.Where(u => u.Deleted).Select(u => u.ExternalId)); } } diff --git a/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs b/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs index 6a6ab15928..742244b624 100644 --- a/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs +++ b/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs @@ -44,6 +44,11 @@ namespace Bit.Api.Utilities context.HttpContext.Response.StatusCode = 400; errorModel = new ErrorResponseModel(stripeException.StripeError.Parameter, stripeException.Message); } + else if(exception is GatewayException) + { + errorModel.Message = exception.Message; + context.HttpContext.Response.StatusCode = 400; + } else if(exception is ApplicationException) { context.HttpContext.Response.StatusCode = 402; diff --git a/src/Core/Enums/PaymentMethodType.cs b/src/Core/Enums/PaymentMethodType.cs new file mode 100644 index 0000000000..99eea1a357 --- /dev/null +++ b/src/Core/Enums/PaymentMethodType.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Enums +{ + public enum PaymentMethodType : byte + { + Card, + BankAccount, + PayPal, + Bitcoin + } +} diff --git a/src/Core/Exceptions/GatewayException.cs b/src/Core/Exceptions/GatewayException.cs new file mode 100644 index 0000000000..8c2949fec8 --- /dev/null +++ b/src/Core/Exceptions/GatewayException.cs @@ -0,0 +1,11 @@ +using System; + +namespace Bit.Core.Exceptions +{ + public class GatewayException : Exception + { + public GatewayException(string message, Exception innerException = null) + : base(message, innerException) + { } + } +} diff --git a/src/Core/Models/Api/Response/BillingResponseModel.cs b/src/Core/Models/Api/Response/BillingResponseModel.cs index 06848db5c9..deed9e1f09 100644 --- a/src/Core/Models/Api/Response/BillingResponseModel.cs +++ b/src/Core/Models/Api/Response/BillingResponseModel.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using Bit.Core.Models.Business; using Stripe; using Bit.Core.Models.Table; +using Bit.Core.Enums; namespace Bit.Core.Models.Api { @@ -32,47 +33,32 @@ namespace Bit.Core.Models.Api public class BillingSource { - public BillingSource(Source source) + public BillingSource(BillingInfo.BillingSource 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; - } + CardBrand = source.CardBrand; + Description = source.Description; } - public SourceType Type { get; set; } + public PaymentMethodType Type { get; set; } public string CardBrand { get; set; } public string Description { get; set; } } public class BillingSubscription { - public BillingSubscription(StripeSubscription sub) + public BillingSubscription(BillingInfo.BillingSubscription sub) { Status = sub.Status; - TrialStartDate = sub.TrialStart; - TrialEndDate = sub.TrialEnd; - EndDate = sub.CurrentPeriodEnd; - CancelledDate = sub.CanceledAt; - CancelAtEndDate = sub.CancelAtPeriodEnd; - if(sub.Items?.Data != null) + TrialStartDate = sub.TrialStartDate; + TrialEndDate = sub.TrialEndDate; + EndDate = sub.EndDate; + CancelledDate = sub.CancelledDate; + CancelAtEndDate = sub.CancelAtEndDate; + Cancelled = Cancelled; + if(sub.Items != null) { - Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); + Items = sub.Items.Select(i => new BillingSubscriptionItem(i)); } } @@ -82,19 +68,16 @@ namespace Bit.Core.Models.Api public DateTime? CancelledDate { get; set; } public bool CancelAtEndDate { get; set; } public string Status { get; set; } + public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); public class BillingSubscriptionItem { - public BillingSubscriptionItem(StripeSubscriptionItem item) + public BillingSubscriptionItem(BillingInfo.BillingSubscription.BillingSubscriptionItem item) { - if(item.Plan != null) - { - Name = item.Plan.Name; - Amount = item.Plan.Amount / 100M; - Interval = item.Plan.Interval; - } - + Name = item.Name; + Amount = item.Amount; + Interval = item.Interval; Quantity = item.Quantity; } @@ -107,10 +90,10 @@ namespace Bit.Core.Models.Api public class BillingInvoice { - public BillingInvoice(StripeInvoice inv) + public BillingInvoice(BillingInfo.BillingInvoice inv) { - Amount = inv.AmountDue / 100M; - Date = inv.Date.Value; + Amount = inv.Amount; + Date = inv.Date; } public decimal Amount { get; set; } @@ -119,12 +102,12 @@ namespace Bit.Core.Models.Api public class BillingCharge { - public BillingCharge(StripeCharge charge) + public BillingCharge(BillingInfo.BillingCharge charge) { - Amount = charge.Amount / 100M; - RefundedAmount = charge.AmountRefunded / 100M; - PaymentSource = charge.Source != null ? new BillingSource(charge.Source) : null; - CreatedDate = charge.Created; + Amount = charge.Amount; + RefundedAmount = charge.RefundedAmount; + PaymentSource = charge.PaymentSource != null ? new BillingSource(charge.PaymentSource) : null; + CreatedDate = charge.CreatedDate; FailureMessage = charge.FailureMessage; Refunded = charge.Refunded; Status = charge.Status; diff --git a/src/Core/Models/Business/BillingInfo.cs b/src/Core/Models/Business/BillingInfo.cs index 310dabdc7c..47491900a8 100644 --- a/src/Core/Models/Business/BillingInfo.cs +++ b/src/Core/Models/Business/BillingInfo.cs @@ -1,13 +1,257 @@ -using Stripe; +using Bit.Core.Enums; +using Braintree; +using Stripe; +using System; using System.Collections.Generic; +using System.Linq; namespace Bit.Core.Models.Business { public class BillingInfo { - public Source PaymentSource { get; set; } - public StripeSubscription Subscription { get; set; } - public StripeInvoice UpcomingInvoice { get; set; } - public IEnumerable Charges { get; set; } = new List(); + public BillingSource PaymentSource { get; set; } + public BillingSubscription Subscription { get; set; } + public BillingInvoice UpcomingInvoice { get; set; } + public IEnumerable Charges { get; set; } = new List(); + + public class BillingSource + { + public BillingSource(Source source) + { + switch(source.Type) + { + case SourceType.Card: + Type = PaymentMethodType.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: + Type = PaymentMethodType.BankAccount; + Description = $"{source.BankAccount.BankName}, *{source.BankAccount.Last4}"; + break; + // bitcoin? + default: + break; + } + } + + public BillingSource(PaymentMethod method) + { + if(method is PayPalAccount paypal) + { + Type = PaymentMethodType.PayPal; + Description = paypal.Email; + } + else if(method is CreditCard card) + { + Type = PaymentMethodType.Card; + Description = $"{card.CardType.ToString()}, *{card.LastFour}, " + + string.Format("{0}/{1}", + string.Concat(card.ExpirationMonth.Length == 1 ? + "0" : string.Empty, card.ExpirationMonth), + card.ExpirationYear); + CardBrand = card.CardType.ToString(); + } + else if(method is UsBankAccount bank) + { + Type = PaymentMethodType.BankAccount; + Description = $"{bank.BankName}, *{bank.Last4}"; + } + else + { + throw new NotSupportedException("Method not supported."); + } + } + + public BillingSource(UsBankAccountDetails bank) + { + Type = PaymentMethodType.BankAccount; + Description = $"{bank.BankName}, *{bank.Last4}"; + } + + public BillingSource(PayPalDetails paypal) + { + Type = PaymentMethodType.PayPal; + Description = paypal.PayerEmail; + } + + public PaymentMethodType 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; + Cancelled = sub.Status == "cancelled"; + if(sub.Items?.Data != null) + { + Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); + } + } + + public BillingSubscription(Subscription sub, Plan plan) + { + Status = sub.Status.ToString(); + + if(sub.HasTrialPeriod.GetValueOrDefault() && sub.CreatedAt.HasValue && sub.TrialDuration.HasValue) + { + TrialStartDate = sub.CreatedAt.Value; + if(sub.TrialDurationUnit == SubscriptionDurationUnit.DAY) + { + TrialEndDate = TrialStartDate.Value.AddDays(sub.TrialDuration.Value); + } + else + { + TrialEndDate = TrialStartDate.Value.AddMonths(sub.TrialDuration.Value); + } + } + + EndDate = sub.BillingPeriodEndDate; + + CancelAtEndDate = !sub.NeverExpires.GetValueOrDefault(); + Cancelled = sub.Status == SubscriptionStatus.CANCELED; + if(Cancelled) + { + CancelledDate = sub.UpdatedAt.Value; + } + + var items = new List(); + items.Add(new BillingSubscriptionItem(plan)); + if(sub.AddOns != null) + { + items.AddRange(sub.AddOns.Select(a => new BillingSubscriptionItem(plan, a))); + } + + if(items.Count > 0) + { + Items = items; + } + } + + 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 bool Cancelled { 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 BillingSubscriptionItem(Plan plan) + { + Name = plan.Name; + Amount = plan.Price.GetValueOrDefault(); + Interval = plan.BillingFrequency.GetValueOrDefault() == 12 ? "year" : "month"; + Quantity = 1; + } + + public BillingSubscriptionItem(Plan plan, AddOn addon) + { + Name = addon.Name; + Amount = addon.Amount.GetValueOrDefault(); + Interval = plan.BillingFrequency.GetValueOrDefault() == 12 ? "year" : "month"; + Quantity = addon.Quantity.GetValueOrDefault(); + } + + 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 BillingInvoice(Subscription sub) + { + Amount = sub.NextBillAmount.GetValueOrDefault(); + Date = sub.NextBillingDate; + } + + 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 BillingCharge(Transaction transaction) + { + Amount = transaction.Amount.GetValueOrDefault(); + RefundedAmount = 0; // TODO? + + if(transaction.PayPalDetails != null) + { + PaymentSource = new BillingSource(transaction.PayPalDetails); + } + else if(transaction.CreditCard != null && transaction.CreditCard.CardType != CreditCardCardType.UNRECOGNIZED) + { + PaymentSource = new BillingSource(transaction.CreditCard); + } + else if(transaction.UsBankAccountDetails != null) + { + PaymentSource = new BillingSource(transaction.UsBankAccountDetails); + } + + CreatedDate = transaction.CreatedAt.GetValueOrDefault(); + FailureMessage = null; + Refunded = transaction.RefundedTransactionId != null; + Status = transaction.Status.ToString(); + InvoiceId = null; + } + + 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/Table/User.cs b/src/Core/Models/Table/User.cs index 0f2e7bd814..eb24be04a3 100644 --- a/src/Core/Models/Table/User.cs +++ b/src/Core/Models/Table/User.cs @@ -4,6 +4,7 @@ using Bit.Core.Utilities; using System.Collections.Generic; using Newtonsoft.Json; using System.Linq; +using Bit.Core.Services; namespace Bit.Core.Models.Table { @@ -135,5 +136,20 @@ namespace Bit.Core.Models.Table return maxStorageBytes - Storage.Value; } + + public IPaymentService GetPaymentService(GlobalSettings globalSettings) + { + IPaymentService paymentService = null; + if(StripeSubscriptionId.StartsWith("sub_")) + { + paymentService = new StripePaymentService(); + } + else + { + paymentService = new BraintreePaymentService(globalSettings); + } + + return paymentService; + } } } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 1e6f66551c..6a939ae32f 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,10 +1,17 @@ using System.Threading.Tasks; using Bit.Core.Models.Table; +using Bit.Core.Models.Business; namespace Bit.Core.Services { public interface IPaymentService { + Task CancelAndRecoverChargesAsync(ISubscriber subscriber); Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb); + Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId); + Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false); + Task ReinstateSubscriptionAsync(ISubscriber subscriber); + Task UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken); + Task GetBillingAsync(ISubscriber subscriber); } } diff --git a/src/Core/Services/Implementations/BraintreePaymentService.cs b/src/Core/Services/Implementations/BraintreePaymentService.cs index 549e9f0aa0..6430da7b0c 100644 --- a/src/Core/Services/Implementations/BraintreePaymentService.cs +++ b/src/Core/Services/Implementations/BraintreePaymentService.cs @@ -1,12 +1,17 @@ using System; +using System.Linq; using System.Threading.Tasks; using Bit.Core.Models.Table; using Braintree; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; namespace Bit.Core.Services { public class BraintreePaymentService : IPaymentService { + private const string PremiumPlanId = "premium-annually"; + private const string StoragePlanId = "storage-gb-annually"; private readonly BraintreeGateway _gateway; public BraintreePaymentService( @@ -22,6 +27,172 @@ namespace Bit.Core.Services }; } + public async Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId) + { + var sub = await _gateway.Subscription.FindAsync(storableSubscriber.StripeSubscriptionId); + if(sub == null) + { + throw new GatewayException("Subscription was not found."); + } + + var req = new SubscriptionRequest + { + AddOns = new AddOnsRequest(), + Options = new SubscriptionOptionsRequest + { + ProrateCharges = true + } + }; + + var storageItem = sub.AddOns?.FirstOrDefault(a => a.Id == storagePlanId); + if(additionalStorage > 0 && storageItem == null) + { + req.AddOns.Add = new AddAddOnRequest[] + { + new AddAddOnRequest + { + InheritedFromId = storagePlanId, + Quantity = additionalStorage, + NeverExpires = true + } + }; + } + else if(additionalStorage > 0 && storageItem != null) + { + req.AddOns.Update = new UpdateAddOnRequest[] + { + new UpdateAddOnRequest + { + ExistingId = storageItem.Id, + Quantity = additionalStorage + } + }; + } + else if(additionalStorage == 0 && storageItem != null) + { + req.AddOns.Remove = new string[] { storageItem.Id }; + } + + var result = await _gateway.Subscription.UpdateAsync(sub.Id, req); + if(!result.IsSuccess()) + { + throw new GatewayException("Failed to adjust storage."); + } + } + + public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber) + { + if(!string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + await _gateway.Subscription.CancelAsync(subscriber.StripeSubscriptionId); + } + + if(string.IsNullOrWhiteSpace(subscriber.StripeCustomerId)) + { + return; + } + + var transactionRequest = new TransactionSearchRequest().CustomerId.Is(subscriber.StripeCustomerId); + var transactions = _gateway.Transaction.Search(transactionRequest); + + if((transactions?.MaximumCount ?? 0) > 0) + { + foreach(var transaction in transactions.Cast().Where(c => c.RefundedTransactionId == null)) + { + await _gateway.Transaction.RefundAsync(transaction.Id); + } + } + + await _gateway.Customer.DeleteAsync(subscriber.StripeCustomerId); + } + + public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false) + { + if(subscriber == null) + { + throw new ArgumentNullException(nameof(subscriber)); + } + + if(string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + throw new GatewayException("No subscription."); + } + + var sub = await _gateway.Subscription.FindAsync(subscriber.StripeSubscriptionId); + if(sub == null) + { + throw new GatewayException("Subscription was not found."); + } + + if(sub.Status == SubscriptionStatus.CANCELED || sub.Status == SubscriptionStatus.EXPIRED || + !sub.NeverExpires.GetValueOrDefault()) + { + throw new GatewayException("Subscription is already canceled."); + } + + if(endOfPeriod) + { + var req = new SubscriptionRequest + { + NeverExpires = false, + NumberOfBillingCycles = sub.CurrentBillingCycle + }; + + var result = await _gateway.Subscription.UpdateAsync(subscriber.StripeSubscriptionId, req); + if(!result.IsSuccess()) + { + throw new GatewayException("Unable to cancel subscription."); + } + } + else + { + var result = await _gateway.Subscription.CancelAsync(subscriber.StripeSubscriptionId); + if(!result.IsSuccess()) + { + throw new GatewayException("Unable to cancel subscription."); + } + } + } + + public async Task GetBillingAsync(ISubscriber subscriber) + { + var billingInfo = new BillingInfo(); + if(!string.IsNullOrWhiteSpace(subscriber.StripeCustomerId)) + { + var customer = await _gateway.Customer.FindAsync(subscriber.StripeCustomerId); + if(customer != null) + { + if(customer.DefaultPaymentMethod != null) + { + billingInfo.PaymentSource = new BillingInfo.BillingSource(customer.DefaultPaymentMethod); + } + + var transactionRequest = new TransactionSearchRequest().CustomerId.Is(customer.Id); + var transactions = _gateway.Transaction.Search(transactionRequest); + billingInfo.Charges = transactions?.Cast().OrderByDescending(t => t.CreatedAt) + .Select(t => new BillingInfo.BillingCharge(t)); + } + } + + if(!string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + var sub = await _gateway.Subscription.FindAsync(subscriber.StripeSubscriptionId); + if(sub != null) + { + var plans = await _gateway.Plan.AllAsync(); + var plan = plans?.FirstOrDefault(p => p.Id == sub.PlanId); + billingInfo.Subscription = new BillingInfo.BillingSubscription(sub, plan); + } + + if(sub.NextBillingDate.HasValue) + { + billingInfo.UpcomingInvoice = new BillingInfo.BillingInvoice(sub); + } + } + + return billingInfo; + } + public async Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb) { var customerResult = await _gateway.Customer.CreateAsync(new CustomerRequest @@ -30,9 +201,9 @@ namespace Bit.Core.Services Email = user.Email }); - if(!customerResult.IsSuccess()) + if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) { - // error, throw something + throw new GatewayException("Failed to create customer."); } var subId = "u" + user.Id.ToString("N").ToLower() + @@ -41,17 +212,18 @@ namespace Bit.Core.Services var subRequest = new SubscriptionRequest { Id = subId, - PaymentMethodToken = paymentToken, - PlanId = "premium-annually" + PaymentMethodToken = customerResult.Target.PaymentMethods[0].Token, + PlanId = PremiumPlanId }; if(additionalStorageGb > 0) { + subRequest.AddOns = new AddOnsRequest(); subRequest.AddOns.Add = new AddAddOnRequest[] { new AddAddOnRequest { - InheritedFromId = "storage-gb-annually", + InheritedFromId = StoragePlanId, Quantity = additionalStorageGb } }; @@ -62,11 +234,104 @@ namespace Bit.Core.Services if(!subResult.IsSuccess()) { await _gateway.Customer.DeleteAsync(customerResult.Target.Id); - // error, throw something + throw new GatewayException("Failed to create subscription."); } user.StripeCustomerId = customerResult.Target.Id; user.StripeSubscriptionId = subResult.Target.Id; } + + public async Task ReinstateSubscriptionAsync(ISubscriber subscriber) + { + if(subscriber == null) + { + throw new ArgumentNullException(nameof(subscriber)); + } + + if(string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + throw new GatewayException("No subscription."); + } + + var sub = await _gateway.Subscription.FindAsync(subscriber.StripeSubscriptionId); + if(sub == null) + { + throw new GatewayException("Subscription was not found."); + } + + if(sub.Status != SubscriptionStatus.ACTIVE || sub.NeverExpires.GetValueOrDefault()) + { + throw new GatewayException("Subscription is not marked for cancellation."); + } + + var req = new SubscriptionRequest + { + NeverExpires = true, + NumberOfBillingCycles = null + }; + + var result = await _gateway.Subscription.UpdateAsync(subscriber.StripeSubscriptionId, req); + if(!result.IsSuccess()) + { + throw new GatewayException("Unable to reinstate subscription."); + } + } + + public async Task UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken) + { + if(subscriber == null) + { + throw new ArgumentNullException(nameof(subscriber)); + } + + var updatedSubscriber = false; + Customer customer = null; + + if(!string.IsNullOrWhiteSpace(subscriber.StripeCustomerId)) + { + customer = await _gateway.Customer.FindAsync(subscriber.StripeCustomerId); + } + + if(customer == null) + { + var result = await _gateway.Customer.CreateAsync(new CustomerRequest + { + Email = subscriber.BillingEmailAddress(), + PaymentMethodNonce = paymentToken + }); + + if(!result.IsSuccess()) + { + throw new GatewayException("Cannot create customer."); + } + + customer = result.Target; + subscriber.StripeCustomerId = customer.Id; + updatedSubscriber = true; + } + else + { + if(customer.DefaultPaymentMethod != null) + { + var deleteResult = await _gateway.PaymentMethod.DeleteAsync(customer.DefaultPaymentMethod.Token); + if(!deleteResult.IsSuccess()) + { + throw new GatewayException("Cannot delete old payment method."); + } + } + + var result = await _gateway.PaymentMethod.CreateAsync(new PaymentMethodRequest + { + PaymentMethodNonce = paymentToken, + CustomerId = customer.Id + }); + if(!result.IsSuccess()) + { + throw new GatewayException("Cannot add new payment method."); + } + } + + return updatedSubscriber; + } } } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 8041ef5a70..ab77884d74 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -25,6 +25,7 @@ namespace Bit.Core.Services private readonly IMailService _mailService; private readonly IPushNotificationService _pushNotificationService; private readonly IPushRegistrationService _pushRegistrationService; + private readonly StripePaymentService _stripePaymentService; public OrganizationService( IOrganizationRepository organizationRepository, @@ -46,6 +47,7 @@ namespace Bit.Core.Services _mailService = mailService; _pushNotificationService = pushNotificationService; _pushRegistrationService = pushRegistrationService; + _stripePaymentService = new StripePaymentService(); } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken) @@ -56,7 +58,7 @@ namespace Bit.Core.Services throw new NotFoundException(); } - var updated = await BillingHelpers.UpdatePaymentMethodAsync(organization, paymentToken); + var updated = await _stripePaymentService.UpdatePaymentMethodAsync(organization, paymentToken); if(updated) { await _organizationRepository.ReplaceAsync(organization); @@ -71,7 +73,7 @@ namespace Bit.Core.Services throw new NotFoundException(); } - await BillingHelpers.CancelSubscriptionAsync(organization, endOfPeriod); + await _stripePaymentService.CancelSubscriptionAsync(organization, endOfPeriod); } public async Task ReinstateSubscriptionAsync(Guid organizationId) @@ -82,7 +84,7 @@ namespace Bit.Core.Services throw new NotFoundException(); } - await BillingHelpers.ReinstateSubscriptionAsync(organization); + await _stripePaymentService.ReinstateSubscriptionAsync(organization); } public async Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats) @@ -241,7 +243,8 @@ namespace Bit.Core.Services throw new BadRequestException("Plan does not allow additional storage."); } - await BillingHelpers.AdjustStorageAsync(organization, storageAdjustmentGb, plan.StripStoragePlanId); + await BillingHelpers.AdjustStorageAsync(_stripePaymentService, organization, storageAdjustmentGb, + plan.StripStoragePlanId); await _organizationRepository.ReplaceAsync(organization); } @@ -337,7 +340,7 @@ namespace Bit.Core.Services if(additionalSeats > 0) { - await BillingHelpers.PreviewUpcomingInvoiceAndPayAsync(organization, plan.StripeSeatPlanId, 500); + await _stripePaymentService.PreviewUpcomingInvoiceAndPayAsync(organization, plan.StripeSeatPlanId, 500); } organization.Seats = (short?)newSeatTotal; @@ -500,7 +503,7 @@ namespace Bit.Core.Services } catch { - await BillingHelpers.CancelAndRecoverChargesAsync(subscription?.Id, customer?.Id); + await _stripePaymentService.CancelAndRecoverChargesAsync(organization); if(organization.Id != default(Guid)) { await _organizationRepository.DeleteAsync(organization); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index aa026e2b57..e7d856ffe9 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -3,6 +3,9 @@ using System.Threading.Tasks; using Bit.Core.Models.Table; using Stripe; using System.Collections.Generic; +using Bit.Core.Exceptions; +using System.Linq; +using Bit.Core.Models.Business; namespace Bit.Core.Services { @@ -11,12 +14,6 @@ namespace Bit.Core.Services private const string PremiumPlanId = "premium-annually"; private const string StoragePlanId = "storage-gb-annually"; - public StripePaymentService( - GlobalSettings globalSettings) - { - - } - public async Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb) { var customerService = new StripeCustomerService(); @@ -66,5 +63,290 @@ namespace Bit.Core.Services user.StripeCustomerId = customer.Id; user.StripeSubscriptionId = subscription.Id; } + + public async Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, + string storagePlanId) + { + var subscriptionItemService = new StripeSubscriptionItemService(); + var subscriptionService = new StripeSubscriptionService(); + var sub = await subscriptionService.GetAsync(storableSubscriber.StripeSubscriptionId); + if(sub == null) + { + throw new GatewayException("Subscription not found."); + } + + var storageItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == storagePlanId); + if(additionalStorage > 0 && storageItem == null) + { + await subscriptionItemService.CreateAsync(new StripeSubscriptionItemCreateOptions + { + PlanId = storagePlanId, + Quantity = additionalStorage, + Prorate = true, + SubscriptionId = sub.Id + }); + } + else if(additionalStorage > 0 && storageItem != null) + { + await subscriptionItemService.UpdateAsync(storageItem.Id, new StripeSubscriptionItemUpdateOptions + { + PlanId = storagePlanId, + Quantity = additionalStorage, + Prorate = true + }); + } + else if(additionalStorage == 0 && storageItem != null) + { + await subscriptionItemService.DeleteAsync(storageItem.Id); + } + + if(additionalStorage > 0) + { + await PreviewUpcomingInvoiceAndPayAsync(storableSubscriber, storagePlanId, 400); + } + } + + public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber) + { + if(!string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + var subscriptionService = new StripeSubscriptionService(); + await subscriptionService.CancelAsync(subscriber.StripeSubscriptionId, false); + } + + if(string.IsNullOrWhiteSpace(subscriber.StripeCustomerId)) + { + return; + } + + var chargeService = new StripeChargeService(); + var charges = await chargeService.ListAsync(new StripeChargeListOptions + { + CustomerId = subscriber.StripeCustomerId + }); + + 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(subscriber.StripeCustomerId); + } + + public 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) { } + } + } + + public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false) + { + if(subscriber == null) + { + throw new ArgumentNullException(nameof(subscriber)); + } + + if(string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + throw new GatewayException("No subscription."); + } + + var subscriptionService = new StripeSubscriptionService(); + var sub = await subscriptionService.GetAsync(subscriber.StripeSubscriptionId); + if(sub == null) + { + throw new GatewayException("Subscription was not found."); + } + + if(sub.CanceledAt.HasValue) + { + throw new GatewayException("Subscription is already canceled."); + } + + var canceledSub = await subscriptionService.CancelAsync(sub.Id, endOfPeriod); + if(!canceledSub.CanceledAt.HasValue) + { + throw new GatewayException("Unable to cancel subscription."); + } + } + + public async Task ReinstateSubscriptionAsync(ISubscriber subscriber) + { + if(subscriber == null) + { + throw new ArgumentNullException(nameof(subscriber)); + } + + if(string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + throw new GatewayException("No subscription."); + } + + var subscriptionService = new StripeSubscriptionService(); + var sub = await subscriptionService.GetAsync(subscriber.StripeSubscriptionId); + if(sub == null) + { + throw new GatewayException("Subscription was not found."); + } + + if((sub.Status != "active" && sub.Status != "trialing") || !sub.CanceledAt.HasValue) + { + throw new GatewayException("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 GatewayException("Unable to reinstate subscription."); + } + } + + public 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; + } + else + { + await cardService.CreateAsync(customer.Id, new StripeCardCreateOptions + { + SourceToken = paymentToken + }); + + if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId)) + { + await cardService.DeleteAsync(customer.Id, customer.DefaultSourceId); + } + } + + return updatedSubscriber; + } + + public async Task GetBillingAsync(ISubscriber subscriber) + { + var billingInfo = new BillingInfo(); + var customerService = new StripeCustomerService(); + var subscriptionService = new StripeSubscriptionService(); + var chargeService = new StripeChargeService(); + var invoiceService = new StripeInvoiceService(); + + if(!string.IsNullOrWhiteSpace(subscriber.StripeCustomerId)) + { + var customer = await customerService.GetAsync(subscriber.StripeCustomerId); + if(customer != null) + { + if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null) + { + if(customer.DefaultSourceId.StartsWith("card_")) + { + var source = customer.Sources.Data.FirstOrDefault(s => s.Card?.Id == customer.DefaultSourceId); + if(source != null) + { + billingInfo.PaymentSource = new BillingInfo.BillingSource(source); + } + } + else if(customer.DefaultSourceId.StartsWith("ba_")) + { + var source = customer.Sources.Data + .FirstOrDefault(s => s.BankAccount?.Id == customer.DefaultSourceId); + if(source != null) + { + billingInfo.PaymentSource = new BillingInfo.BillingSource(source); + } + } + } + + var charges = await chargeService.ListAsync(new StripeChargeListOptions + { + CustomerId = customer.Id, + Limit = 20 + }); + billingInfo.Charges = charges?.Data?.OrderByDescending(c => c.Created) + .Select(c => new BillingInfo.BillingCharge(c)); + } + } + + if(!string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId)) + { + var sub = await subscriptionService.GetAsync(subscriber.StripeSubscriptionId); + if(sub != null) + { + billingInfo.Subscription = new BillingInfo.BillingSubscription(sub); + } + + if(!sub.CanceledAt.HasValue && !string.IsNullOrWhiteSpace(subscriber.StripeCustomerId)) + { + try + { + var upcomingInvoice = await invoiceService.UpcomingAsync(subscriber.StripeCustomerId); + if(upcomingInvoice != null) + { + billingInfo.UpcomingInvoice = new BillingInfo.BillingInvoice(upcomingInvoice); + } + } + catch(StripeException) { } + } + } + + return billingInfo; + } } } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 8060e86cca..f683569296 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -514,7 +514,16 @@ namespace Bit.Core.Services throw new BadRequestException("Already a premium user."); } - IPaymentService paymentService = new StripePaymentService(_globalSettings); + IPaymentService paymentService = null; + if(paymentToken.StartsWith("tok_")) + { + paymentService = new StripePaymentService(); + } + else + { + paymentService = new BraintreePaymentService(_globalSettings); + } + await paymentService.PurchasePremiumAsync(user, paymentToken, additionalStorageGb); user.Premium = true; @@ -527,7 +536,7 @@ namespace Bit.Core.Services } catch { - await BillingHelpers.CancelAndRecoverChargesAsync(user.StripeSubscriptionId, user.StripeCustomerId); + await paymentService.CancelAndRecoverChargesAsync(user); throw; } } @@ -544,13 +553,15 @@ namespace Bit.Core.Services throw new BadRequestException("Not a premium user."); } - await BillingHelpers.AdjustStorageAsync(user, storageAdjustmentGb, StoragePlanId); + var paymentService = user.GetPaymentService(_globalSettings); + await BillingHelpers.AdjustStorageAsync(paymentService, user, storageAdjustmentGb, StoragePlanId); await SaveUserAsync(user); } public async Task ReplacePaymentMethodAsync(User user, string paymentToken) { - var updated = await BillingHelpers.UpdatePaymentMethodAsync(user, paymentToken); + var paymentService = user.GetPaymentService(_globalSettings); + var updated = await paymentService.UpdatePaymentMethodAsync(user, paymentToken); if(updated) { await SaveUserAsync(user); @@ -559,12 +570,14 @@ namespace Bit.Core.Services public async Task CancelPremiumAsync(User user, bool endOfPeriod = false) { - await BillingHelpers.CancelSubscriptionAsync(user, endOfPeriod); + var paymentService = user.GetPaymentService(_globalSettings); + await paymentService.CancelSubscriptionAsync(user, endOfPeriod); } public async Task ReinstatePremiumAsync(User user) { - await BillingHelpers.ReinstateSubscriptionAsync(user); + var paymentService = user.GetPaymentService(_globalSettings); + await paymentService.ReinstateSubscriptionAsync(user); } public async Task DisablePremiumAsync(Guid userId) diff --git a/src/Core/Utilities/BillingHelpers.cs b/src/Core/Utilities/BillingHelpers.cs index e8d61b7213..975d7b1ce2 100644 --- a/src/Core/Utilities/BillingHelpers.cs +++ b/src/Core/Utilities/BillingHelpers.cs @@ -1,139 +1,15 @@ using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Models.Table; -using Stripe; +using Bit.Core.Services; 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) + internal static async Task AdjustStorageAsync(IPaymentService paymentService, IStorableSubscriber storableSubscriber, + short storageAdjustmentGb, string storagePlanId) { if(storableSubscriber == null) { @@ -175,152 +51,8 @@ namespace Bit.Core.Utilities } 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 storageItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == storagePlanId); - if(additionalStorage > 0 && storageItem == null) - { - await subscriptionItemService.CreateAsync(new StripeSubscriptionItemCreateOptions - { - PlanId = storagePlanId, - Quantity = additionalStorage, - Prorate = true, - SubscriptionId = sub.Id - }); - } - else if(additionalStorage > 0 && storageItem != null) - { - await subscriptionItemService.UpdateAsync(storageItem.Id, new StripeSubscriptionItemUpdateOptions - { - PlanId = storagePlanId, - Quantity = additionalStorage, - Prorate = true - }); - } - else if(additionalStorage == 0 && storageItem != null) - { - await subscriptionItemService.DeleteAsync(storageItem.Id); - } - - if(additionalStorage > 0) - { - await PreviewUpcomingInvoiceAndPayAsync(storableSubscriber, storagePlanId, 400); - } - + await paymentService.AdjustStorageAsync(storableSubscriber, additionalStorage, storagePlanId); 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.Status != "trialing") || !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."); - } - } } }