Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using DevBetterWeb.Infrastructure.IssuingHandler.StripeIssuingHandler;
using DevBetterWeb.Infrastructure.Logging;
using DevBetterWeb.Infrastructure.Services;
using DevBetterWeb.Infrastructure.SubscriptionHandler.StripeSubscriptionHandler;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.Extensions.DependencyInjection;
using Stripe;
Expand Down Expand Up @@ -74,6 +75,7 @@ public static IServiceCollection AddInfrastructureServices(this IServiceCollecti
services.AddTransient<IIssuingHandlerCardListService, StripeIssuingHandlerCardListService>();
services.AddTransient<IIssuingHandlerTransactionListService, StripeIssuingHandlerTransactionListService>();
services.AddTransient<IInvoiceHandlerListService, StripeInvoiceHandlerListService>();
services.AddTransient<ISubscriptionHandlerService, StripeSubscriptionHandlerService>();

// Vimeo
services.RegisterVimeoServicesDependencies(vimeoToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Stripe;

namespace DevBetterWeb.Infrastructure.Interfaces;

public interface ISubscriptionHandlerService
{
Task<List<Subscription>> ListBillableAsync(CancellationToken cancellationToken = default);
Task<Subscription> PauseAsync(string subscriptionId, CancellationToken cancellationToken = default);
Task<Subscription> ResumeAsync(string subscriptionId, CancellationToken cancellationToken = default);
Task<Subscription> CancelAtPeriodEndAsync(string subscriptionId, CancellationToken cancellationToken = default);
Task<Subscription> CancelImmediatelyAsync(string subscriptionId, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DevBetterWeb.Infrastructure.Interfaces;
using Stripe;

namespace DevBetterWeb.Infrastructure.SubscriptionHandler.StripeSubscriptionHandler;

public class StripeSubscriptionHandlerService : ISubscriptionHandlerService
{
private static readonly HashSet<string> _billableStatuses =
new() { "active", "past_due", "trialing", "unpaid" };

private readonly SubscriptionService _subscriptionService;

public StripeSubscriptionHandlerService(SubscriptionService subscriptionService)
{
_subscriptionService = subscriptionService;
}

public async Task<List<Subscription>> ListBillableAsync(CancellationToken cancellationToken = default)
{
var options = new SubscriptionListOptions { Status = "all", Limit = 100 };
options.AddExpand("data.customer");

var subscriptions = new List<Subscription>();
StripeList<Subscription> page;
do
{
page = await _subscriptionService.ListAsync(options, cancellationToken: cancellationToken);
subscriptions.AddRange(page.Data);
if (page.Data.Count > 0)
{
options.StartingAfter = page.Data[^1].Id;
}
} while (page.HasMore && page.Data.Count > 0);

return subscriptions.Where(s => _billableStatuses.Contains(s.Status)).ToList();
}

public Task<Subscription> PauseAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
var options = new SubscriptionUpdateOptions
{
PauseCollection = new SubscriptionPauseCollectionOptions
{
Behavior = "void",
},
};

return _subscriptionService.UpdateAsync(subscriptionId, options, cancellationToken: cancellationToken);
}

public Task<Subscription> ResumeAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
var options = new SubscriptionUpdateOptions();
options.AddExtraParam("pause_collection", "");

return _subscriptionService.UpdateAsync(subscriptionId, options, cancellationToken: cancellationToken);
}

public Task<Subscription> CancelAtPeriodEndAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
var options = new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = true,
};

return _subscriptionService.UpdateAsync(subscriptionId, options, cancellationToken: cancellationToken);
}

public Task<Subscription> CancelImmediatelyAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
return _subscriptionService.CancelAsync(subscriptionId, null, cancellationToken: cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public override async Task<ActionResult> HandleAsync(CancellationToken cancellat

_logger.LogInformation($"Processing Stripe Event Type: {stripeEvent.Type}");

if (stripeEvent.Type != EventTypes.CustomerUpdated)
if (stripeEvent.Type != EventTypes.CustomerSubscriptionUpdated)
{
throw new Exception($"Unhandled Stripe event type {stripeEvent.Type}");
}
Expand Down
28 changes: 28 additions & 0 deletions src/DevBetterWeb.Web/MappingProfiles/SubscriptionProfile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Linq;
using AutoMapper;
using DevBetterWeb.Web.Models;
using Stripe;

namespace DevBetterWeb.Web.MappingProfiles;

public class SubscriptionProfile : Profile
{
public SubscriptionProfile()
{
CreateMap<Subscription, StripeSubscriptionDto>()
.ForMember(dest => dest.CustomerEmail,
opt => opt.MapFrom((src, _) => src.Customer?.Email ?? string.Empty))
.ForMember(dest => dest.PlanName,
opt => opt.MapFrom((src, _) => src.Items?.Data?.FirstOrDefault()?.Price?.Nickname ?? string.Empty))
.ForMember(dest => dest.Amount,
opt => opt.MapFrom((src, _) => (src.Items?.Data?.FirstOrDefault()?.Price?.UnitAmountDecimal ?? 0m) / 100m))
.ForMember(dest => dest.Currency,
opt => opt.MapFrom((src, _) => src.Items?.Data?.FirstOrDefault()?.Price?.Currency ?? string.Empty))
.ForMember(dest => dest.Interval,
opt => opt.MapFrom((src, _) => src.Items?.Data?.FirstOrDefault()?.Price?.Recurring?.Interval ?? string.Empty))
.ForMember(dest => dest.CurrentPeriodEnd,
opt => opt.MapFrom((src, _) => src.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd ?? default))
.ForMember(dest => dest.IsPaused,
opt => opt.MapFrom((src, _) => src.PauseCollection != null));
}
}
18 changes: 18 additions & 0 deletions src/DevBetterWeb.Web/Models/StripeSubscriptionDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;

namespace DevBetterWeb.Web.Models;

public class StripeSubscriptionDto
{
public string Id { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string CustomerId { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string PlanName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Currency { get; set; } = string.Empty;
public string Interval { get; set; } = string.Empty;
public DateTime CurrentPeriodEnd { get; set; }
public bool CancelAtPeriodEnd { get; set; }
public bool IsPaused { get; set; }
}
5 changes: 5 additions & 0 deletions src/DevBetterWeb.Web/Pages/Admin/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
<i class="fas fa-file-contract fa-fw" aria-hidden="true"></i> Manage Subscription Plans
</a>
</li>
<li class="list-group-item list-group-item-action">
<a href="./Admin/ManageSubscriptions">
<i class="fas fa-credit-card fa-fw" aria-hidden="true"></i> Manage Subscriptions
</a>
</li>
<li class="list-group-item list-group-item-action">
<a href="./Admin/UserReports">
<i class="fas fa-file-alt fa-fw" aria-hidden="true"></i> View Reports
Expand Down
87 changes: 87 additions & 0 deletions src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
@page
@model DevBetterWeb.Web.Pages.Admin.ManageSubscriptions.IndexModel

@{
ViewData["Title"] = "Manage Subscriptions";
}

<h2>Manage Subscriptions</h2>

@if (!string.IsNullOrEmpty(Model.StatusMessage))
{
<div class="alert alert-info">@Model.StatusMessage</div>
}

<div class="card">
<div class="card-header">
<b>Billable Stripe Subscriptions (@Model.Subscriptions.Count)</b>
</div>
<div class="card-body" style="overflow: auto;">
<table class="table">
<thead>
<tr>
<th>Customer Email</th>
<th>Plan</th>
<th>Amount</th>
<th>Interval</th>
<th>Status</th>
<th>Current Period End</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var subscription in Model.Subscriptions)
{
<tr>
<td>@subscription.CustomerEmail<br /><small class="text-muted">@subscription.Id</small></td>
<td>@subscription.PlanName</td>
<td>$@subscription.Amount.ToString("F2") @subscription.Currency.ToUpper()</td>
<td>@subscription.Interval</td>
<td>
@subscription.Status
@if (subscription.IsPaused)
{
<span class="badge badge-warning">Paused</span>
}
@if (subscription.CancelAtPeriodEnd)
{
<span class="badge badge-secondary">Cancels at period end</span>
}
</td>
<td>@subscription.CurrentPeriodEnd.ToString("MM/dd/yyyy")</td>
<td style="white-space: nowrap;">
@if (subscription.IsPaused)
{
<form method="post" asp-page-handler="Resume" style="display:inline">
<input type="hidden" name="subscriptionId" value="@subscription.Id" />
<button type="submit" class="btn btn-sm btn-success">Resume</button>
</form>
}
else
{
<form method="post" asp-page-handler="Pause" style="display:inline"
onsubmit="return confirm('Pause collection for this customer? Stripe will stop charging until resumed.');">
<input type="hidden" name="subscriptionId" value="@subscription.Id" />
<button type="submit" class="btn btn-sm btn-warning">Pause</button>
</form>
}
@if (!subscription.CancelAtPeriodEnd)
{
<form method="post" asp-page-handler="Cancel" style="display:inline"
onsubmit="return confirm('Cancel this subscription at period end?');">
<input type="hidden" name="subscriptionId" value="@subscription.Id" />
<button type="submit" class="btn btn-sm btn-outline-danger">Cancel at Period End</button>
</form>
}
<form method="post" asp-page-handler="CancelNow" style="display:inline"
onsubmit="return confirm('IMMEDIATELY cancel this subscription? This cannot be undone.');">
<input type="hidden" name="subscriptionId" value="@subscription.Id" />
<button type="submit" class="btn btn-sm btn-danger">Cancel Now</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutoMapper;
using DevBetterWeb.Core;
using DevBetterWeb.Infrastructure.Interfaces;
using DevBetterWeb.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Stripe;

namespace DevBetterWeb.Web.Pages.Admin.ManageSubscriptions;

[Authorize(Roles = AuthConstants.Roles.ADMINISTRATORS)]
public class IndexModel : PageModel
{
private readonly ISubscriptionHandlerService _subscriptionHandlerService;
private readonly IMapper _mapper;

public List<StripeSubscriptionDto> Subscriptions { get; private set; } = new();

[TempData]
public string? StatusMessage { get; set; }

public IndexModel(ISubscriptionHandlerService subscriptionHandlerService, IMapper mapper)
{
_subscriptionHandlerService = subscriptionHandlerService;
_mapper = mapper;
}

public async Task OnGetAsync()
{
var subscriptions = await _subscriptionHandlerService.ListBillableAsync(HttpContext.RequestAborted);
Subscriptions = _mapper.Map<List<StripeSubscriptionDto>>(subscriptions)
.OrderBy(s => s.CustomerEmail)
.ThenBy(s => s.Id)
.ToList();
}

public Task<IActionResult> OnPostPauseAsync(string subscriptionId)
=> ExecuteActionAsync(subscriptionId, id => _subscriptionHandlerService.PauseAsync(id, HttpContext.RequestAborted),
"paused (Stripe will not charge until resumed)");

public Task<IActionResult> OnPostResumeAsync(string subscriptionId)
=> ExecuteActionAsync(subscriptionId, id => _subscriptionHandlerService.ResumeAsync(id, HttpContext.RequestAborted),
"resumed");

public Task<IActionResult> OnPostCancelAsync(string subscriptionId)
=> ExecuteActionAsync(subscriptionId, id => _subscriptionHandlerService.CancelAtPeriodEndAsync(id, HttpContext.RequestAborted),
"set to cancel at period end");

public Task<IActionResult> OnPostCancelNowAsync(string subscriptionId)
=> ExecuteActionAsync(subscriptionId, id => _subscriptionHandlerService.CancelImmediatelyAsync(id, HttpContext.RequestAborted),
"canceled immediately");

private async Task<IActionResult> ExecuteActionAsync(string subscriptionId,
Func<string, Task<Subscription>> action, string successVerb)
{
if (string.IsNullOrWhiteSpace(subscriptionId))
{
StatusMessage = "No subscription id provided.";
return RedirectToPage();
}

try
{
await action(subscriptionId);
StatusMessage = $"Subscription {subscriptionId} {successVerb}.";
}
catch (StripeException exception)
{
StatusMessage = $"Stripe error for {subscriptionId}: {exception.Message}";
}

return RedirectToPage();
}
}
Loading
Loading