From c5852db6ed0a9c679398fd8be0407ebec1d14c23 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Wed, 13 Jul 2022 10:04:58 -0400 Subject: [PATCH] [feat] Allow CS to perform bulk actions on Stripe subscriptions from the Admin portal (#2116) * [feat] Allow CS to perform bulk actions on Stripe subscriptions from the Admin portal * [fix] An unrelated lint error --- src/Admin/Controllers/ToolsController.cs | 128 ++++++++- src/Admin/Models/StripeSubscriptionsModel.cs | 42 +++ src/Admin/Views/Shared/_Layout.cshtml | 3 + .../Views/Tools/StripeSubscriptions.cshtml | 265 ++++++++++++++++++ .../Stripe/StripeSubscriptionListOptions.cs | 41 +++ src/Core/Services/IStripeAdapter.cs | 8 +- .../Services/Implementations/StripeAdapter.cs | 29 +- 7 files changed, 510 insertions(+), 6 deletions(-) create mode 100644 src/Admin/Models/StripeSubscriptionsModel.cs create mode 100644 src/Admin/Views/Tools/StripeSubscriptions.cshtml create mode 100644 src/Core/Models/Stripe/StripeSubscriptionListOptions.cs diff --git a/src/Admin/Controllers/ToolsController.cs b/src/Admin/Controllers/ToolsController.cs index 11871c3ce7..0f3023cee5 100644 --- a/src/Admin/Controllers/ToolsController.cs +++ b/src/Admin/Controllers/ToolsController.cs @@ -1,6 +1,8 @@ -using System.Text.Json; +using System.Text; +using System.Text.Json; using Bit.Admin.Models; using Bit.Core.Entities; +using Bit.Core.Models.BitStripe; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -23,6 +25,7 @@ namespace Bit.Admin.Controllers private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPaymentService _paymentService; private readonly ITaxRateRepository _taxRateRepository; + private readonly IStripeAdapter _stripeAdapter; public ToolsController( GlobalSettings globalSettings, @@ -33,7 +36,8 @@ namespace Bit.Admin.Controllers IInstallationRepository installationRepository, IOrganizationUserRepository organizationUserRepository, ITaxRateRepository taxRateRepository, - IPaymentService paymentService) + IPaymentService paymentService, + IStripeAdapter stripeAdapter) { _globalSettings = globalSettings; _organizationRepository = organizationRepository; @@ -44,6 +48,7 @@ namespace Bit.Admin.Controllers _organizationUserRepository = organizationUserRepository; _taxRateRepository = taxRateRepository; _paymentService = paymentService; + _stripeAdapter = stripeAdapter; } public IActionResult ChargeBraintree() @@ -429,5 +434,124 @@ namespace Bit.Admin.Controllers return RedirectToAction("TaxRate"); } + + public async Task StripeSubscriptions(StripeSubscriptionListOptions options) + { + options = options ?? new StripeSubscriptionListOptions(); + options.Limit = 10; + options.Expand = new List() { "data.customer", "data.latest_invoice" }; + options.SelectAll = false; + + var subscriptions = await _stripeAdapter.SubscriptionListAsync(options); + + options.StartingAfter = subscriptions.LastOrDefault()?.Id; + options.EndingBefore = await StripeSubscriptionsGetHasPreviousPage(subscriptions, options) ? + subscriptions.FirstOrDefault()?.Id : + null; + + var model = new StripeSubscriptionsModel() + { + Items = subscriptions.Select(s => new StripeSubscriptionRowModel(s)).ToList(), + Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data, + Filter = options + }; + return View(model); + } + + [HttpPost] + public async Task StripeSubscriptions([FromForm] StripeSubscriptionsModel model) + { + if (!ModelState.IsValid) + { + model.Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data; + return View(model); + } + + if (model.Action == StripeSubscriptionsAction.Export || model.Action == StripeSubscriptionsAction.BulkCancel) + { + var subscriptions = model.Filter.SelectAll ? + await _stripeAdapter.SubscriptionListAsync(model.Filter) : + model.Items.Where(x => x.Selected).Select(x => x.Subscription); + + if (model.Action == StripeSubscriptionsAction.Export) + { + return StripeSubscriptionsExport(subscriptions); + } + + if (model.Action == StripeSubscriptionsAction.BulkCancel) + { + await StripeSubscriptionsCancel(subscriptions); + } + } + else + { + if (model.Action == StripeSubscriptionsAction.PreviousPage) + { + model.Filter.StartingAfter = null; + } + if (model.Action == StripeSubscriptionsAction.NextPage) + { + model.Filter.EndingBefore = null; + } + } + + + return RedirectToAction("StripeSubscriptions", model.Filter); + } + + // This requires a redundant API call to Stripe because of the way they handle pagination. + // The StartingBefore value has to be infered from the list we get, and isn't supplied by Stripe. + private async Task StripeSubscriptionsGetHasPreviousPage(List subscriptions, StripeSubscriptionListOptions options) + { + var hasPreviousPage = false; + if (subscriptions.FirstOrDefault()?.Id != null) + { + var previousPageSearchOptions = new StripeSubscriptionListOptions() + { + EndingBefore = subscriptions.FirstOrDefault().Id, + Limit = 1, + Status = options.Status, + CurrentPeriodEndDate = options.CurrentPeriodEndDate, + CurrentPeriodEndRange = options.CurrentPeriodEndRange, + Price = options.Price + }; + hasPreviousPage = (await _stripeAdapter.SubscriptionListAsync(previousPageSearchOptions)).Count > 0; + } + return hasPreviousPage; + } + + private async Task StripeSubscriptionsCancel(IEnumerable subscriptions) + { + foreach (var s in subscriptions) + { + await _stripeAdapter.SubscriptionCancelAsync(s.Id); + if (s.LatestInvoice?.Status == "open") + { + await _stripeAdapter.InvoiceVoidInvoiceAsync(s.LatestInvoiceId); + } + } + } + + private FileResult StripeSubscriptionsExport(IEnumerable subscriptions) + { + var fieldsToExport = subscriptions.Select(s => new + { + StripeId = s.Id, + CustomerEmail = s.Customer?.Email, + SubscriptionStatus = s.Status, + InvoiceDueDate = s.CurrentPeriodEnd, + SubscriptionProducts = s.Items?.Data.Select(p => p.Plan.Id) + }); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + var result = System.Text.Json.JsonSerializer.Serialize(fieldsToExport, options); + var bytes = Encoding.UTF8.GetBytes(result); + return File(bytes, "application/json", "StripeSubscriptionsSearch.json"); + } } } diff --git a/src/Admin/Models/StripeSubscriptionsModel.cs b/src/Admin/Models/StripeSubscriptionsModel.cs new file mode 100644 index 0000000000..2596db9abd --- /dev/null +++ b/src/Admin/Models/StripeSubscriptionsModel.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Models.BitStripe; + +namespace Bit.Admin.Models +{ + public class StripeSubscriptionRowModel + { + public Stripe.Subscription Subscription { get; set; } + public bool Selected { get; set; } + + public StripeSubscriptionRowModel() { } + public StripeSubscriptionRowModel(Stripe.Subscription subscription) + { + Subscription = subscription; + } + } + + public enum StripeSubscriptionsAction + { + Search, + PreviousPage, + NextPage, + Export, + BulkCancel + } + + public class StripeSubscriptionsModel : IValidatableObject + { + public List Items { get; set; } + public StripeSubscriptionsAction Action { get; set; } = StripeSubscriptionsAction.Search; + public string Message { get; set; } + public List Prices { get; set; } + public StripeSubscriptionListOptions Filter { get; set; } = new StripeSubscriptionListOptions(); + public IEnumerable Validate(ValidationContext validationContext) + { + if (Action == StripeSubscriptionsAction.BulkCancel && Filter.Status != "unpaid") + { + yield return new ValidationResult("Bulk cancel is currently only supported for unpaid subscriptions"); + } + } + } +} diff --git a/src/Admin/Views/Shared/_Layout.cshtml b/src/Admin/Views/Shared/_Layout.cshtml index 9165424dca..3954091335 100644 --- a/src/Admin/Views/Shared/_Layout.cshtml +++ b/src/Admin/Views/Shared/_Layout.cshtml @@ -66,6 +66,9 @@ Manage Tax Rates + + Manage Stripe Subscriptions +