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..fdf7fa28b --- /dev/null +++ b/src/DevBetterWeb.Infrastructure/SubscriptionHandler/StripeSubscriptionHandler/StripeSubscriptionHandlerService.cs @@ -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 _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 && page.Data.Count > 0); + + return subscriptions.Where(s => _billableStatuses.Contains(s.Status)).ToList(); + } + + public Task PauseAsync(string subscriptionId, CancellationToken cancellationToken = default) + { + var options = new SubscriptionUpdateOptions + { + PauseCollection = new SubscriptionPauseCollectionOptions + { + Behavior = "void", + }, + }; + + return _subscriptionService.UpdateAsync(subscriptionId, options, cancellationToken: cancellationToken); + } + + public Task ResumeAsync(string subscriptionId, CancellationToken cancellationToken = default) + { + var options = new SubscriptionUpdateOptions(); + options.AddExtraParam("pause_collection", ""); + + return _subscriptionService.UpdateAsync(subscriptionId, options, cancellationToken: cancellationToken); + } + + public Task CancelAtPeriodEndAsync(string subscriptionId, CancellationToken cancellationToken = default) + { + var options = new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = true, + }; + + return _subscriptionService.UpdateAsync(subscriptionId, options, cancellationToken: cancellationToken); + } + + public Task CancelImmediatelyAsync(string subscriptionId, CancellationToken cancellationToken = default) + { + return _subscriptionService.CancelAsync(subscriptionId, null, cancellationToken: cancellationToken); + } +} 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/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/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 new file mode 100644 index 000000000..482818856 --- /dev/null +++ b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml @@ -0,0 +1,87 @@ +@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") + @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 new file mode 100644 index 000000000..eea47f6fe --- /dev/null +++ b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs @@ -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 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(); + } + + 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/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); + } +} diff --git a/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs new file mode 100644 index 000000000..8705309ae --- /dev/null +++ b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs @@ -0,0 +1,76 @@ +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() + { + // 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_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(); + + // 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); + } +} 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); + } +} 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()); + } +} 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()); + } +} 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()); + } +}