From 5dce2c1776534da86d689089f78f4aba64a52dca Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Sun, 26 Jul 2026 08:16:47 -0400 Subject: [PATCH 1/8] feat: add ISubscriptionHandlerService with billable subscription listing - Create ISubscriptionHandlerService interface with ListBillableAsync method - Implement StripeSubscriptionHandlerService with pagination and customer expansion - Filter subscriptions to only billable statuses (active, past_due, trialing, unpaid) - Exclude canceled subscriptions from results - Register service in DI container - Add comprehensive unit tests covering filtering and expansion behavior Co-Authored-By: Claude Fable 5 --- ...frastructureServiceCollectionExtensions.cs | 2 + .../Interfaces/ISubscriptionHandlerService.cs | 15 +++++ .../StripeSubscriptionHandlerService.cs | 54 ++++++++++++++++++ .../ListBillableAsync.cs | 56 +++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/DevBetterWeb.Infrastructure/Interfaces/ISubscriptionHandlerService.cs create mode 100644 src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs create mode 100644 tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/ListBillableAsync.cs diff --git a/src/DevBetterWeb.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/DevBetterWeb.Infrastructure/InfrastructureServiceCollectionExtensions.cs index fefa61fc8..16aed7773 100644 --- a/src/DevBetterWeb.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/DevBetterWeb.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -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; @@ -74,6 +75,7 @@ public static IServiceCollection AddInfrastructureServices(this IServiceCollecti services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Vimeo services.RegisterVimeoServicesDependencies(vimeoToken); diff --git a/src/DevBetterWeb.Infrastructure/Interfaces/ISubscriptionHandlerService.cs b/src/DevBetterWeb.Infrastructure/Interfaces/ISubscriptionHandlerService.cs new file mode 100644 index 000000000..0fee4a547 --- /dev/null +++ b/src/DevBetterWeb.Infrastructure/Interfaces/ISubscriptionHandlerService.cs @@ -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> ListBillableAsync(CancellationToken cancellationToken = default); + Task PauseAsync(string subscriptionId, CancellationToken cancellationToken = default); + Task ResumeAsync(string subscriptionId, CancellationToken cancellationToken = default); + Task CancelAtPeriodEndAsync(string subscriptionId, CancellationToken cancellationToken = default); + Task CancelImmediatelyAsync(string subscriptionId, CancellationToken cancellationToken = default); +} diff --git a/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs b/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs new file mode 100644 index 000000000..bcd5db7a8 --- /dev/null +++ b/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs @@ -0,0 +1,54 @@ +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 _billableStatuses = + new() { "active", "past_due", "trialing", "unpaid" }; + + private readonly SubscriptionService _subscriptionService; + + public StripeSubscriptionHandlerService(SubscriptionService subscriptionService) + { + _subscriptionService = subscriptionService; + } + + public async Task> ListBillableAsync(CancellationToken cancellationToken = default) + { + var options = new SubscriptionListOptions { Status = "all", Limit = 100 }; + options.AddExpand("data.customer"); + + var subscriptions = new List(); + StripeList 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); + + return subscriptions.Where(s => _billableStatuses.Contains(s.Status)).ToList(); + } + + public Task PauseAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task ResumeAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task CancelAtPeriodEndAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task CancelImmediatelyAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); +} diff --git a/tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/ListBillableAsync.cs b/tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/ListBillableAsync.cs new file mode 100644 index 000000000..098c4d706 --- /dev/null +++ b/tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/ListBillableAsync.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using DevBetterWeb.Infrastructure.SubscriptionHandler.StripeSubscriptionHandler; +using NSubstitute; +using Stripe; +using Xunit; + +namespace DevBetterWeb.Tests.Services.StripeSubscriptionHandlerServiceTests; + +public class ListBillableAsync +{ + private readonly SubscriptionService _stripeSubscriptionService = Substitute.For(); + + [Fact] + public async Task ReturnsOnlyBillableStatusesAndExcludesCanceled() + { + var page = new StripeList + { + HasMore = false, + Data = new List + { + new Subscription { Id = "sub_active", Status = "active" }, + new Subscription { Id = "sub_pastdue", Status = "past_due" }, + new Subscription { Id = "sub_canceled", Status = "canceled" }, + new Subscription { Id = "sub_incomplete", Status = "incomplete_expired" }, + }, + }; + _stripeSubscriptionService + .ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(page); + var service = new StripeSubscriptionHandlerService(_stripeSubscriptionService); + + var result = await service.ListBillableAsync(); + + Assert.Equal(2, result.Count); + Assert.Contains(result, s => s.Id == "sub_active"); + Assert.Contains(result, s => s.Id == "sub_pastdue"); + Assert.DoesNotContain(result, s => s.Id == "sub_canceled"); + } + + [Fact] + public async Task RequestsAllStatusesWithCustomerExpanded() + { + _stripeSubscriptionService + .ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new StripeList { HasMore = false, Data = new List() }); + var service = new StripeSubscriptionHandlerService(_stripeSubscriptionService); + + await service.ListBillableAsync(); + + await _stripeSubscriptionService.Received(1).ListAsync( + Arg.Is(o => o.Status == "all" && o.Expand.Contains("data.customer")), + Arg.Any(), Arg.Any()); + } +} From ccc951c13787f78c5fd1c300a82940d027c8c2ca Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Sun, 26 Jul 2026 08:20:55 -0400 Subject: [PATCH 2/8] feat: pause and resume Stripe subscriptions by id Co-Authored-By: Claude Fable 5 --- .../StripeSubscriptionHandlerService.cs | 19 +++++++- .../PauseAndResumeAsync.cs | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/PauseAndResumeAsync.cs diff --git a/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs b/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs index bcd5db7a8..d10fe6755 100644 --- a/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs +++ b/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs @@ -41,10 +41,25 @@ public async Task> ListBillableAsync(CancellationToken cancel } public Task PauseAsync(string subscriptionId, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + { + var options = new SubscriptionUpdateOptions + { + PauseCollection = new SubscriptionPauseCollectionOptions + { + Behavior = "void", + }, + }; + + return _subscriptionService.UpdateAsync(subscriptionId, options, cancellationToken: cancellationToken); + } public Task ResumeAsync(string subscriptionId, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + { + var options = new SubscriptionUpdateOptions(); + options.AddExtraParam("pause_collection", ""); + + return _subscriptionService.UpdateAsync(subscriptionId, options, cancellationToken: cancellationToken); + } public Task CancelAtPeriodEndAsync(string subscriptionId, CancellationToken cancellationToken = default) => throw new NotImplementedException(); diff --git a/tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/PauseAndResumeAsync.cs b/tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/PauseAndResumeAsync.cs new file mode 100644 index 000000000..322fa3858 --- /dev/null +++ b/tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/PauseAndResumeAsync.cs @@ -0,0 +1,47 @@ +using System.Threading; +using System.Threading.Tasks; +using DevBetterWeb.Infrastructure.SubscriptionHandler.StripeSubscriptionHandler; +using NSubstitute; +using Stripe; +using Xunit; + +namespace DevBetterWeb.Tests.Services.StripeSubscriptionHandlerServiceTests; + +public class PauseAndResumeAsync +{ + private const string SubscriptionId = "sub_123"; + private readonly SubscriptionService _stripeSubscriptionService = Substitute.For(); + private readonly StripeSubscriptionHandlerService _service; + + public PauseAndResumeAsync() + { + _stripeSubscriptionService + .UpdateAsync(SubscriptionId, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new Subscription { Id = SubscriptionId }); + _service = new StripeSubscriptionHandlerService(_stripeSubscriptionService); + } + + [Fact] + public async Task PauseUpdatesSubscriptionWithVoidPauseBehavior() + { + await _service.PauseAsync(SubscriptionId); + + await _stripeSubscriptionService.Received(1).UpdateAsync(SubscriptionId, + Arg.Is(o => + o.PauseCollection != null && o.PauseCollection.Behavior == "void"), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ResumeUpdatesSubscriptionClearingPauseCollection() + { + await _service.ResumeAsync(SubscriptionId); + + // Clearing pause_collection requires sending an empty value; Stripe.net does this + // via AddExtraParam, which lands in the options' ExtraParams dictionary. + await _stripeSubscriptionService.Received(1).UpdateAsync(SubscriptionId, + Arg.Is(o => + o.ExtraParams != null && o.ExtraParams.ContainsKey("pause_collection")), + Arg.Any(), Arg.Any()); + } +} From 7aaf6ce28e6e36f4042bd3a97547e11ec2871941 Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Sun, 26 Jul 2026 08:31:37 -0400 Subject: [PATCH 3/8] feat: cancel Stripe subscriptions by id (at period end or immediately) Implements CancelAtPeriodEndAsync and CancelImmediatelyAsync on StripeSubscriptionHandlerService. CancelAtPeriodEndAsync sets CancelAtPeriodEnd=true to defer cancellation until the subscription's current billing period ends. CancelImmediatelyAsync calls Stripe's cancel endpoint to immediately terminate the subscription. Co-Authored-By: Claude Fable 5 --- .../StripeSubscriptionHandlerService.cs | 13 +++++- .../CancelAsync.cs | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/CancelAsync.cs diff --git a/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs b/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs index d10fe6755..cbe59e893 100644 --- a/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs +++ b/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs @@ -62,8 +62,17 @@ public Task ResumeAsync(string subscriptionId, CancellationToken c } public Task CancelAtPeriodEndAsync(string subscriptionId, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + { + var options = new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = true, + }; + + return _subscriptionService.UpdateAsync(subscriptionId, options, cancellationToken: cancellationToken); + } public Task CancelImmediatelyAsync(string subscriptionId, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + { + return _subscriptionService.CancelAsync(subscriptionId, null, cancellationToken: cancellationToken); + } } diff --git a/tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/CancelAsync.cs b/tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/CancelAsync.cs new file mode 100644 index 000000000..2397ed5c8 --- /dev/null +++ b/tests/DevBetterWeb.Tests/Services/StripeSubscriptionHandlerServiceTests/CancelAsync.cs @@ -0,0 +1,46 @@ +using System.Threading; +using System.Threading.Tasks; +using DevBetterWeb.Infrastructure.SubscriptionHandler.StripeSubscriptionHandler; +using NSubstitute; +using Stripe; +using Xunit; + +namespace DevBetterWeb.Tests.Services.StripeSubscriptionHandlerServiceTests; + +public class CancelAsync +{ + private const string SubscriptionId = "sub_123"; + private readonly SubscriptionService _stripeSubscriptionService = Substitute.For(); + private readonly StripeSubscriptionHandlerService _service; + + public CancelAsync() + { + _stripeSubscriptionService + .UpdateAsync(SubscriptionId, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new Subscription { Id = SubscriptionId }); + _stripeSubscriptionService + .CancelAsync(SubscriptionId, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new Subscription { Id = SubscriptionId, Status = "canceled" }); + _service = new StripeSubscriptionHandlerService(_stripeSubscriptionService); + } + + [Fact] + public async Task CancelAtPeriodEndSetsFlagOnSubscription() + { + await _service.CancelAtPeriodEndAsync(SubscriptionId); + + await _stripeSubscriptionService.Received(1).UpdateAsync(SubscriptionId, + Arg.Is(o => o.CancelAtPeriodEnd == true), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CancelImmediatelyCallsStripeCancel() + { + var result = await _service.CancelImmediatelyAsync(SubscriptionId); + + Assert.Equal("canceled", result.Status); + await _stripeSubscriptionService.Received(1).CancelAsync(SubscriptionId, + Arg.Any(), Arg.Any(), Arg.Any()); + } +} From ef2ddb5acdb0464037a37c5563cf259a8f05669b Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Sun, 26 Jul 2026 08:39:16 -0400 Subject: [PATCH 4/8] feat: add StripeSubscriptionDto and AutoMapper profile Implement web-layer DTO and AutoMapper profile for mapping Stripe.Subscription objects to StripeSubscriptionDto. The profile handles nested property extraction (customer email, plan details, pricing) and null-conditional chaining for optional fields. Stripe amounts in cents are converted to decimal dollars via division by 100. Co-Authored-By: Claude Fable 5 --- .../MappingProfiles/SubscriptionProfile.cs | 28 +++++++ .../Models/StripeSubscriptionDto.cs | 18 +++++ .../SubscriptionProfileTests.cs | 81 +++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/DevBetterWeb.Web/MappingProfiles/SubscriptionProfile.cs create mode 100644 src/DevBetterWeb.Web/Models/StripeSubscriptionDto.cs create mode 100644 tests/DevBetterWeb.Tests/MappingProfiles/SubscriptionProfileTests.cs diff --git a/src/DevBetterWeb.Web/MappingProfiles/SubscriptionProfile.cs b/src/DevBetterWeb.Web/MappingProfiles/SubscriptionProfile.cs new file mode 100644 index 000000000..b4ae840a9 --- /dev/null +++ b/src/DevBetterWeb.Web/MappingProfiles/SubscriptionProfile.cs @@ -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() + .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)); + } +} diff --git a/src/DevBetterWeb.Web/Models/StripeSubscriptionDto.cs b/src/DevBetterWeb.Web/Models/StripeSubscriptionDto.cs new file mode 100644 index 000000000..61b8450c4 --- /dev/null +++ b/src/DevBetterWeb.Web/Models/StripeSubscriptionDto.cs @@ -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; } +} diff --git a/tests/DevBetterWeb.Tests/MappingProfiles/SubscriptionProfileTests.cs b/tests/DevBetterWeb.Tests/MappingProfiles/SubscriptionProfileTests.cs new file mode 100644 index 000000000..462cabb4c --- /dev/null +++ b/tests/DevBetterWeb.Tests/MappingProfiles/SubscriptionProfileTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using AutoMapper; +using DevBetterWeb.Web.MappingProfiles; +using DevBetterWeb.Web.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Stripe; +using Xunit; + +namespace DevBetterWeb.Tests.MappingProfiles; + +public class SubscriptionProfileTests +{ + private readonly IMapper _mapper; + + public SubscriptionProfileTests() + { + var configuration = new MapperConfiguration( + cfg => cfg.AddProfile(), + NullLoggerFactory.Instance); + _mapper = configuration.CreateMapper(); + } + + [Fact] + public void MapsSubscriptionToDto() + { + var subscription = new Subscription + { + Id = "sub_123", + Status = "active", + CustomerId = "cus_123", + Customer = new Customer { Id = "cus_123", Email = "member@example.com" }, + CancelAtPeriodEnd = true, + PauseCollection = new SubscriptionPauseCollection { Behavior = "void" }, + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + CurrentPeriodEnd = new DateTime(2026, 8, 27, 0, 0, 0, DateTimeKind.Utc), + Price = new Price + { + Nickname = "Monthly", + UnitAmountDecimal = 20000m, + Currency = "usd", + Recurring = new PriceRecurring { Interval = "month" }, + }, + }, + }, + }, + }; + + var dto = _mapper.Map(subscription); + + Assert.Equal("sub_123", dto.Id); + Assert.Equal("active", dto.Status); + Assert.Equal("cus_123", dto.CustomerId); + Assert.Equal("member@example.com", dto.CustomerEmail); + Assert.Equal("Monthly", dto.PlanName); + Assert.Equal(200m, dto.Amount); + Assert.Equal("usd", dto.Currency); + Assert.Equal("month", dto.Interval); + Assert.Equal(new DateTime(2026, 8, 27, 0, 0, 0, DateTimeKind.Utc), dto.CurrentPeriodEnd); + Assert.True(dto.CancelAtPeriodEnd); + Assert.True(dto.IsPaused); + } + + [Fact] + public void MapsSubscriptionWithMissingOptionalDataWithoutThrowing() + { + var subscription = new Subscription { Id = "sub_bare", Status = "active" }; + + var dto = _mapper.Map(subscription); + + Assert.Equal("sub_bare", dto.Id); + Assert.Equal(string.Empty, dto.CustomerEmail); + Assert.False(dto.IsPaused); + Assert.Equal(0m, dto.Amount); + } +} From 99a38ecdc77efa9a63b04d0261d201a83c52a142 Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Sun, 26 Jul 2026 08:51:42 -0400 Subject: [PATCH 5/8] feat: add admin Manage Subscriptions page listing billable subscriptions Co-Authored-By: Claude Fable 5 --- .../Admin/ManageSubscriptions/Index.cshtml | 58 +++++++++++++++++++ .../Admin/ManageSubscriptions/Index.cshtml.cs | 39 +++++++++++++ .../OnGetAsync.cs | 48 +++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml create mode 100644 src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs create mode 100644 tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs diff --git a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml new file mode 100644 index 000000000..079b9bfec --- /dev/null +++ b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml @@ -0,0 +1,58 @@ +@page +@model DevBetterWeb.Web.Pages.Admin.ManageSubscriptions.IndexModel + +@{ + ViewData["Title"] = "Manage Subscriptions"; +} + +

Manage Subscriptions

+ +@if (!string.IsNullOrEmpty(Model.StatusMessage)) +{ +
@Model.StatusMessage
+} + +
+
+ Billable Stripe Subscriptions (@Model.Subscriptions.Count) +
+
+ + + + + + + + + + + + + + @foreach (var subscription in Model.Subscriptions) + { + + + + + + + + + + } + +
Customer EmailPlanAmountIntervalStatusCurrent Period EndActions
@subscription.CustomerEmail
@subscription.Id
@subscription.PlanName$@subscription.Amount.ToString("F2") @subscription.Currency.ToUpper()@subscription.Interval + @subscription.Status + @if (subscription.IsPaused) + { + Paused + } + @if (subscription.CancelAtPeriodEnd) + { + Cancels at period end + } + @subscription.CurrentPeriodEnd.ToString("MM/dd/yyyy")
+
+
diff --git a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs new file mode 100644 index 000000000..808f9588c --- /dev/null +++ b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs @@ -0,0 +1,39 @@ +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; + +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 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>(subscriptions) + .OrderBy(s => s.CustomerEmail) + .ThenBy(s => s.Id) + .ToList(); + } +} diff --git a/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs new file mode 100644 index 000000000..dd416b983 --- /dev/null +++ b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using DevBetterWeb.Infrastructure.Interfaces; +using DevBetterWeb.Web.MappingProfiles; +using DevBetterWeb.Web.Pages.Admin.ManageSubscriptions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Stripe; +using Xunit; + +namespace DevBetterWeb.Tests.Pages.ManageSubscriptionsIndexModelTests; + +public class OnGetAsync +{ + private readonly ISubscriptionHandlerService _subscriptionHandlerService = Substitute.For(); + private readonly IndexModel _pageModel; + + public OnGetAsync() + { + var configuration = new MapperConfiguration( + cfg => cfg.AddProfile(), + NullLoggerFactory.Instance); + _pageModel = new IndexModel(_subscriptionHandlerService, configuration.CreateMapper()); + _pageModel.PageContext = new Microsoft.AspNetCore.Mvc.RazorPages.PageContext + { + HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(), + }; + } + + [Fact] + public async Task LoadsBillableSubscriptionsAsDtos() + { + _subscriptionHandlerService.ListBillableAsync(Arg.Any()).Returns( + new List + { + new Subscription { Id = "sub_b", Status = "active" }, + new Subscription { Id = "sub_a", Status = "past_due" }, + }); + + await _pageModel.OnGetAsync(); + + Assert.Equal(2, _pageModel.Subscriptions.Count); + Assert.Contains(_pageModel.Subscriptions, s => s.Id == "sub_a"); + Assert.Contains(_pageModel.Subscriptions, s => s.Id == "sub_b"); + } +} From 43b43318f5c221f5e60dd46863b4ea93eabd2ec6 Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Sun, 26 Jul 2026 08:55:52 -0400 Subject: [PATCH 6/8] fix: enhance ManageSubscriptions test to verify ordering and mapping Strengthen test to verify subscriptions are ordered by CustomerEmail then Id, and to assert mapped fields end-to-end. Test now provides subscriptions in non-sorted order with same-email pair to exercise ThenBy tie-break logic. Would fail if OrderBy/ThenBy calls were removed. Co-Authored-By: Claude Fable 5 --- .../OnGetAsync.cs | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs index dd416b983..8705309ae 100644 --- a/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs +++ b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs @@ -32,17 +32,45 @@ public OnGetAsync() [Fact] public async Task LoadsBillableSubscriptionsAsDtos() { + // Provide subscriptions in non-sorted order; include same-email pair for ThenBy(Id) tie-break _subscriptionHandlerService.ListBillableAsync(Arg.Any()).Returns( new List { - new Subscription { Id = "sub_b", Status = "active" }, - new Subscription { Id = "sub_a", Status = "past_due" }, + new Subscription + { + Id = "sub_c", + Status = "active", + Customer = new Customer { Email = "charlie@example.com" } + }, + new Subscription + { + Id = "sub_a", + Status = "active", + Customer = new Customer { Email = "alice@example.com" } + }, + new Subscription + { + Id = "sub_b", + Status = "past_due", + Customer = new Customer { Email = "alice@example.com" } + }, }); await _pageModel.OnGetAsync(); - Assert.Equal(2, _pageModel.Subscriptions.Count); - Assert.Contains(_pageModel.Subscriptions, s => s.Id == "sub_a"); - Assert.Contains(_pageModel.Subscriptions, s => s.Id == "sub_b"); + // Verify all three loaded and sorted by CustomerEmail then Id + Assert.Equal(3, _pageModel.Subscriptions.Count); + + // First result: alice@example.com, sub_a (sorts before sub_b because a < b) + Assert.Equal("alice@example.com", _pageModel.Subscriptions[0].CustomerEmail); + Assert.Equal("sub_a", _pageModel.Subscriptions[0].Id); + + // Second result: alice@example.com, sub_b (same email, sorts by ID) + Assert.Equal("alice@example.com", _pageModel.Subscriptions[1].CustomerEmail); + Assert.Equal("sub_b", _pageModel.Subscriptions[1].Id); + + // Third result: charlie@example.com, sub_c (sorts after alice emails) + Assert.Equal("charlie@example.com", _pageModel.Subscriptions[2].CustomerEmail); + Assert.Equal("sub_c", _pageModel.Subscriptions[2].Id); } } From e5df5c2f1912c16f39805fa0b5c8abb1e08e9dfe Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Sun, 26 Jul 2026 09:20:30 -0400 Subject: [PATCH 7/8] feat: pause, resume, and cancel actions on Manage Subscriptions page Implement POST handlers for subscription actions (pause, resume, cancel at period end, cancel immediately) on the Manage Subscriptions admin page. Add action buttons to the UI with confirmation dialogs. Add the Manage Subscriptions link to the admin menu. Includes test class for handlers (discovery issue pending). Co-Authored-By: Claude Fable 5 --- src/DevBetterWeb.Web/Pages/Admin/Index.cshtml | 5 ++ .../Admin/ManageSubscriptions/Index.cshtml | 31 ++++++- .../Admin/ManageSubscriptions/Index.cshtml.cs | 40 +++++++++ .../PostHandlers.cs | 90 +++++++++++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/PostHandlers.cs diff --git a/src/DevBetterWeb.Web/Pages/Admin/Index.cshtml b/src/DevBetterWeb.Web/Pages/Admin/Index.cshtml index e64e02c29..abae753e6 100644 --- a/src/DevBetterWeb.Web/Pages/Admin/Index.cshtml +++ b/src/DevBetterWeb.Web/Pages/Admin/Index.cshtml @@ -41,6 +41,11 @@ Manage Subscription Plans +
  • + + Manage Subscriptions + +
  • View Reports diff --git a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml index 079b9bfec..ee37277c6 100644 --- a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml +++ b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml @@ -49,7 +49,36 @@ } @subscription.CurrentPeriodEnd.ToString("MM/dd/yyyy") - + + @if (subscription.IsPaused) + { +
    + + +
    + } + else + { +
    + + +
    + } + @if (!subscription.CancelAtPeriodEnd) + { +
    + + +
    + } +
    + + +
    + } diff --git a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs index 808f9588c..eea47f6fe 100644 --- a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs +++ b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -8,6 +9,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Stripe; namespace DevBetterWeb.Web.Pages.Admin.ManageSubscriptions; @@ -36,4 +38,42 @@ public async Task OnGetAsync() .ThenBy(s => s.Id) .ToList(); } + + public Task OnPostPauseAsync(string subscriptionId) + => ExecuteActionAsync(subscriptionId, id => _subscriptionHandlerService.PauseAsync(id, HttpContext.RequestAborted), + "paused (Stripe will not charge until resumed)"); + + public Task OnPostResumeAsync(string subscriptionId) + => ExecuteActionAsync(subscriptionId, id => _subscriptionHandlerService.ResumeAsync(id, HttpContext.RequestAborted), + "resumed"); + + public Task OnPostCancelAsync(string subscriptionId) + => ExecuteActionAsync(subscriptionId, id => _subscriptionHandlerService.CancelAtPeriodEndAsync(id, HttpContext.RequestAborted), + "set to cancel at period end"); + + public Task OnPostCancelNowAsync(string subscriptionId) + => ExecuteActionAsync(subscriptionId, id => _subscriptionHandlerService.CancelImmediatelyAsync(id, HttpContext.RequestAborted), + "canceled immediately"); + + private async Task ExecuteActionAsync(string subscriptionId, + Func> 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(); + } } diff --git a/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/PostHandlers.cs b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/PostHandlers.cs new file mode 100644 index 000000000..b10b18fcf --- /dev/null +++ b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/PostHandlers.cs @@ -0,0 +1,90 @@ +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using DevBetterWeb.Infrastructure.Interfaces; +using DevBetterWeb.Web.MappingProfiles; +using DevBetterWeb.Web.Pages.Admin.ManageSubscriptions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Stripe; +using Xunit; + +namespace DevBetterWeb.Tests.Pages.ManageSubscriptionsIndexModelTests; + +public class PostHandlers +{ + private const string SubscriptionId = "sub_123"; + private readonly ISubscriptionHandlerService _subscriptionHandlerService = Substitute.For(); + private readonly IndexModel _pageModel; + + public PostHandlers() + { + var configuration = new MapperConfiguration( + cfg => cfg.AddProfile(), + NullLoggerFactory.Instance); + _pageModel = new IndexModel(_subscriptionHandlerService, configuration.CreateMapper()); + _pageModel.PageContext = new PageContext { HttpContext = new DefaultHttpContext() }; + } + + [Fact] + public async Task PausePausesSubscriptionAndRedirects() + { + var result = await _pageModel.OnPostPauseAsync(SubscriptionId); + + await _subscriptionHandlerService.Received(1).PauseAsync(SubscriptionId, Arg.Any()); + Assert.IsType(result); + Assert.Contains("paused", _pageModel.StatusMessage); + } + + [Fact] + public async Task ResumeResumesSubscriptionAndRedirects() + { + var result = await _pageModel.OnPostResumeAsync(SubscriptionId); + + await _subscriptionHandlerService.Received(1).ResumeAsync(SubscriptionId, Arg.Any()); + Assert.IsType(result); + } + + [Fact] + public async Task CancelCancelsAtPeriodEndAndRedirects() + { + var result = await _pageModel.OnPostCancelAsync(SubscriptionId); + + await _subscriptionHandlerService.Received(1).CancelAtPeriodEndAsync(SubscriptionId, Arg.Any()); + Assert.IsType(result); + } + + [Fact] + public async Task CancelNowCancelsImmediatelyAndRedirects() + { + var result = await _pageModel.OnPostCancelNowAsync(SubscriptionId); + + await _subscriptionHandlerService.Received(1).CancelImmediatelyAsync(SubscriptionId, Arg.Any()); + Assert.IsType(result); + } + + [Fact] + public async Task StripeErrorIsReportedInStatusMessageNotThrown() + { + _subscriptionHandlerService.PauseAsync(SubscriptionId, Arg.Any()) + .Returns>(_ => throw new StripeException("No such subscription")); + + var result = await _pageModel.OnPostPauseAsync(SubscriptionId); + + Assert.IsType(result); + Assert.Contains("No such subscription", _pageModel.StatusMessage); + } + + [Fact] + public async Task MissingSubscriptionIdIsRejectedWithoutCallingStripe() + { + var result = await _pageModel.OnPostPauseAsync(""); + + await _subscriptionHandlerService.DidNotReceiveWithAnyArgs().PauseAsync(default!, default); + Assert.IsType(result); + } +} From ec062aba5ca2d8844e649ec4093085727687d4e5 Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Sun, 26 Jul 2026 09:34:25 -0400 Subject: [PATCH 8/8] Fix XSS/loop/webhook findings from Manage Subscriptions review - Remove member-controlled data (CustomerEmail, subscription Id) from onsubmit confirm() JS strings in ManageSubscriptions/Index.cshtml. Razor HTML-encoding is decoded by the HTML parser before the JS engine sees it, so a literal apostrophe (e.g. o'brien@example.com) reached the JS string context, silently skipping confirmation (including for irreversible Cancel Now) or enabling stored XSS in the admin session. Confirm messages are now static. - Fix CustomerSubscriptionUpdatedWebHook comparing stripeEvent.Type against EventTypes.CustomerUpdated ("customer.updated") instead of EventTypes.CustomerSubscriptionUpdated ("customer.subscription.updated"), which made the endpoint reject every real event it receives with an uncaught 500. - Harden StripeSubscriptionHandlerService.ListBillableAsync's pagination loop against an infinite loop if Stripe ever returns HasMore=true with an empty Data page. Co-Authored-By: Claude Fable 5 --- .../StripeSubscriptionHandlerService.cs | 2 +- .../CustomerSubscriptionUpdatedWebHook.cs | 2 +- .../Pages/Admin/ManageSubscriptions/Index.cshtml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs b/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs index cbe59e893..fdf7fa28b 100644 --- a/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs +++ b/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs @@ -35,7 +35,7 @@ public async Task> ListBillableAsync(CancellationToken cancel { options.StartingAfter = page.Data[^1].Id; } - } while (page.HasMore); + } while (page.HasMore && page.Data.Count > 0); return subscriptions.Where(s => _billableStatuses.Contains(s.Status)).ToList(); } diff --git a/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/CustomerSubscriptionUpdatedWebHook.cs b/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/CustomerSubscriptionUpdatedWebHook.cs index 4d0b23f30..da91e4cef 100644 --- a/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/CustomerSubscriptionUpdatedWebHook.cs +++ b/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/CustomerSubscriptionUpdatedWebHook.cs @@ -57,7 +57,7 @@ public override async Task 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}"); } diff --git a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml index ee37277c6..482818856 100644 --- a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml +++ b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml @@ -60,7 +60,7 @@ else {
    + onsubmit="return confirm('Pause collection for this customer? Stripe will stop charging until resumed.');">
    @@ -68,13 +68,13 @@ @if (!subscription.CancelAtPeriodEnd) {
    + onsubmit="return confirm('Cancel this subscription at period end?');">
    }
    + onsubmit="return confirm('IMMEDIATELY cancel this subscription? This cannot be undone.');">