1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-05 00:01:30 +01:00

[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
This commit is contained in:
Addison Beck 2022-07-13 10:04:58 -04:00 committed by GitHub
parent 4b43951b59
commit c5852db6ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 510 additions and 6 deletions

View File

@ -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<IActionResult> StripeSubscriptions(StripeSubscriptionListOptions options)
{
options = options ?? new StripeSubscriptionListOptions();
options.Limit = 10;
options.Expand = new List<string>() { "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<IActionResult> 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<bool> StripeSubscriptionsGetHasPreviousPage(List<Stripe.Subscription> 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<Stripe.Subscription> 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<Stripe.Subscription> 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");
}
}
}

View File

@ -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<StripeSubscriptionRowModel> Items { get; set; }
public StripeSubscriptionsAction Action { get; set; } = StripeSubscriptionsAction.Search;
public string Message { get; set; }
public List<Stripe.Price> Prices { get; set; }
public StripeSubscriptionListOptions Filter { get; set; } = new StripeSubscriptionListOptions();
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Action == StripeSubscriptionsAction.BulkCancel && Filter.Status != "unpaid")
{
yield return new ValidationResult("Bulk cancel is currently only supported for unpaid subscriptions");
}
}
}
}

View File

@ -66,6 +66,9 @@
<a class="dropdown-item" asp-controller="Tools" asp-action="TaxRate">
Manage Tax Rates
</a>
<a class="dropdown-item" asp-controller="Tools" asp-action="StripeSubscriptions">
Manage Stripe Subscriptions
</a>
</div>
</li>
<li class="nav-item" active-controller="Logs">

View File

@ -0,0 +1,265 @@
@model StripeSubscriptionsModel
@{
ViewData["Title"] = "Stripe Subscriptions";
}
@section Scripts {
<script>
function onRowSelect(selectingPage = false) {
var checkboxes = document.getElementsByClassName('row-check');
var checkedCheckboxCount = 0;
var bulkActions = document.getElementById('bulkActions');
var selectPage = document.getElementById('selectPage');
for(var i = 0; i < checkboxes.length; i++){
if((checkboxes[i].checked && !selectingPage) || selectingPage && selectPage.checked) {
checkboxes[i].checked = true;
checkedCheckboxCount += 1;
} else {
checkboxes[i].checked = false;
}
}
if(checkedCheckboxCount > 0) {
bulkActions.classList.remove("d-none");
} else {
bulkActions.classList.add("d-none");
}
var selectPage = document.getElementById('selectPage');
var selectAll = document.getElementById('selectAll');
if (checkedCheckboxCount == checkboxes.length) {
selectPage.checked = true;
selectAll.classList.remove("d-none");
var selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.remove('d-none');
var selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.add('d-none');
} else {
selectPage.checked = false;
selectAll.classList.add("d-none");
var selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = false;
}
}
function onSelectAll() {
var selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = true;
var selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.add('d-none');
var selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.remove('d-none');
}
function exportSelectedSubscriptions() {
var selectAll = document.getElementById('selectAll');
var httpRequest = new XMLHttpRequest();
httpRequest.open('POST');
httpRequest.send();
}
function cancelSelectedSubscriptions() {
}
</script>
}
<h2>Manage Stripe Subscriptions</h2>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<div class="alert alert-success"></div>
}
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row">
<div class="col-6">
<label asp-for="Filter.Status">Status</label>
<select asp-for="Filter.Status" name="filter.Status" class="form-control mr-2">
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
</select>
</div>
<div class="col-6">
<label asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
<div class="input-group">
<div class="input-group-append">
<div class="input-group-text">
<span class="mr-1">
<input type="radio" class="mr-1" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="lt">Before
</span>
<input type="radio" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="gt">After
</div>
</div>
@{
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
}
</div>
</div>
</div>
<div class="row mt-2">
<div class="col-6">
<label asp-for="Filter.Price">Price ID</label>
<select asp-for="Filter.Price" name="filter.Price" class="form-control mr-2">
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices)
{<option asp-selected='@Model.Filter.Price == @price.Id' value="@price.Id">@price.Id</option>}
</select>
</div>
</div>
<div class="row col-12 d-flex justify-content-end my-3">
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search"><i class="fa fa-search"></i> Search</button>
</div>
<hr/>
<input type="checkbox" class="d-none" asp-for="Filter.SelectAll" name="filter.SelectAll" id="selectAllInput" asp-for="Model.Filter.SelectAll">
<div class="text-center row d-flex justify-content-center">
<div id="selectAll" class="d-none col-8">
All @Model.Items.Count subscriptions on this page are selected.<br/>
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
<span id="selectedAllConfirmation" class="d-none text-muted">✔ All subscriptions for this search are selected.</span><br/>
<div class="alert alert-warning" role="alert">Please be aware that bulk operations may take several minutes to complete.</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>
<div class="form-check form-check-inline">
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
</div>
</th>
<th>Id</th>
<th>Customer Email</th>
<th>Status</th>
<th>Product</th>
<th>Current Period End</th>
</tr>
</thead>
<tbody>
@if(!Model.Items.Any())
{
<tr>
<td colspan="6">No results to list.</td>
</tr>
}
else
{
@for(var i = 0; i < Model.Items.Count; i++)
{
<tr>
<td>
<input type="hidden" asp-for="@Model.Items[i].Subscription.Id" value="@Model.Items[i].Subscription.Id">
<input type="hidden" asp-for="@Model.Items[i].Subscription.Status" value="@Model.Items[i].Subscription.Status">
<input type="hidden" asp-for="@Model.Items[i].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
<input type="hidden" asp-for="@Model.Items[i].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
<input type="hidden" asp-for="@Model.Items[i].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
<input type="hidden" asp-for="@Model.Items[i].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
@for(var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
{
<input
type="hidden"
asp-for="@Model.Items[i].Subscription.Items.Data[j].Plan.Id"
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id"
>
}
<div class="form-check">
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i].Selected">
</div>
</td>
<td>
@Model.Items[i].Subscription.Id
</td>
<td>
@Model.Items[i].Subscription.Customer?.Email
</td>
<td>
@Model.Items[i].Subscription.Status
</td>
<td>
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
</td>
<td>
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
</td>
</tr>
}
}
</tbody>
</table>
</div>
<nav class="d-inline-flex">
<ul class="pagination">
@if(!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
<li class="page-item">
<button
type="submit"
class="page-link"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.PreviousPage"
>
Previous
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
@if(!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
{
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
<li class="page-item">
<button class="page-link"
type="submit"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.NextPage"
>
Next
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
<span id="bulkActions" class="d-none ml-2">
<span class="d-inline-flex">
<button
type="submit"
class="btn btn-primary mr-1"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.Export"
>
Export
</button>
<button
type="submit"
class="btn btn-danger"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.BulkCancel"
>
Bulk Cancel
</button>
</span>
</span>
</nav>
</form>

View File

@ -0,0 +1,41 @@
namespace Bit.Core.Models.BitStripe
{
// Stripe's SubscriptionListOptions model has a complex input for date filters.
// It expects a dictionary, and has lots of validation rules around what can have a value and what can't.
// To simplify this a bit we are extending Stripe's model and using our own date inputs, and building the dictionary they expect JiT.
// ___
// Our model also facilitates selecting all elements in a list, which is unsupported by Stripe's model.
public class StripeSubscriptionListOptions : Stripe.SubscriptionListOptions
{
public DateTime? CurrentPeriodEndDate { get; set; }
public string CurrentPeriodEndRange { get; set; } = "lt";
public bool SelectAll { get; set; }
public new Stripe.DateRangeOptions CurrentPeriodEnd
{
get
{
return CurrentPeriodEndDate.HasValue ?
new Stripe.DateRangeOptions()
{
LessThan = CurrentPeriodEndRange == "lt" ? CurrentPeriodEndDate : null,
GreaterThan = CurrentPeriodEndRange == "gt" ? CurrentPeriodEndDate : null
} :
null;
}
}
public Stripe.SubscriptionListOptions ToStripeApiOptions()
{
var stripeApiOptions = (Stripe.SubscriptionListOptions)this;
if (CurrentPeriodEndDate.HasValue)
{
stripeApiOptions.CurrentPeriodEnd = new Stripe.DateRangeOptions()
{
LessThan = CurrentPeriodEndRange == "lt" ? CurrentPeriodEndDate : null,
GreaterThan = CurrentPeriodEndRange == "gt" ? CurrentPeriodEndDate : null
};
}
return stripeApiOptions;
}
}
}

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Services
using Bit.Core.Models.BitStripe;
namespace Bit.Core.Services
{
public interface IStripeAdapter
{
@ -8,8 +10,9 @@
Task<Stripe.Customer> CustomerDeleteAsync(string id);
Task<Stripe.Subscription> SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions);
Task<Stripe.Subscription> SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null);
Task<List<Stripe.Subscription>> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions);
Task<Stripe.Subscription> SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null);
Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options);
Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null);
Task<Stripe.Invoice> InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options);
Task<Stripe.Invoice> InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options);
Task<Stripe.StripeList<Stripe.Invoice>> InvoiceListAsync(Stripe.InvoiceListOptions options);
@ -31,5 +34,6 @@
Task<Stripe.Card> CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null);
Task<Stripe.BankAccount> BankAccountCreateAsync(string customerId, Stripe.BankAccountCreateOptions options = null);
Task<Stripe.BankAccount> BankAccountDeleteAsync(string customerId, string bankAccount, Stripe.BankAccountDeleteOptions options = null);
Task<Stripe.StripeList<Stripe.Price>> PriceListAsync(Stripe.PriceListOptions options = null);
}
}

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Services
using Bit.Core.Models.BitStripe;
namespace Bit.Core.Services
{
public class StripeAdapter : IStripeAdapter
{
@ -12,6 +14,7 @@
private readonly Stripe.RefundService _refundService;
private readonly Stripe.CardService _cardService;
private readonly Stripe.BankAccountService _bankAccountService;
private readonly Stripe.PriceService _priceService;
public StripeAdapter()
{
@ -25,6 +28,7 @@
_refundService = new Stripe.RefundService();
_cardService = new Stripe.CardService();
_bankAccountService = new Stripe.BankAccountService();
_priceService = new Stripe.PriceService();
}
public Task<Stripe.Customer> CustomerCreateAsync(Stripe.CustomerCreateOptions options)
@ -63,7 +67,7 @@
return _subscriptionService.UpdateAsync(id, options);
}
public Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options)
public Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null)
{
return _subscriptionService.CancelAsync(Id, options);
}
@ -173,5 +177,26 @@
{
return _bankAccountService.DeleteAsync(customerId, bankAccount, options);
}
public async Task<List<Stripe.Subscription>> SubscriptionListAsync(StripeSubscriptionListOptions options)
{
if (!options.SelectAll)
{
return (await _subscriptionService.ListAsync(options.ToStripeApiOptions())).Data;
}
options.Limit = 100;
var items = new List<Stripe.Subscription>();
await foreach (var i in _subscriptionService.ListAutoPagingAsync(options.ToStripeApiOptions()))
{
items.Add(i);
}
return items;
}
public async Task<Stripe.StripeList<Stripe.Price>> PriceListAsync(Stripe.PriceListOptions options = null)
{
return await _priceService.ListAsync(options);
}
}
}