diff --git a/.tools/nimble-metrics/README.md b/.tools/nimble-metrics/README.md new file mode 100644 index 000000000..3911afbf9 --- /dev/null +++ b/.tools/nimble-metrics/README.md @@ -0,0 +1,14 @@ +# NimbleMetrics + +This directory holds the repository-local configuration for +[NimbleMetrics](https://docs.nimbletools.io), a tool that measures C# code complexity, +change risk (CRAP), build/test timing, and code "concerns" over your repository's +history so you can find and track the riskiest code. + +Learn more at https://docs.nimbletools.io. + +## Contents + +- `settings.json` — the settings the `nm` CLI reads. It ships with + the default configuration; edit it to enable history rebuild, concern collection, and + other options. A `settings.toml` placed here overrides values in this file. diff --git a/.tools/nimble-metrics/settings.json b/.tools/nimble-metrics/settings.json new file mode 100644 index 000000000..785a4fc75 --- /dev/null +++ b/.tools/nimble-metrics/settings.json @@ -0,0 +1,16 @@ +{ + "rebuildHistory": { + "enabled": false, + "maxTotalDataPoints": 25, + "minimumTimeGranularity": "days" + }, + "concerns": { + "enabled": false, + "regionRetention": 0 + }, + "filters": { + "exclude": [ + "**/wwwroot/**" + ] + } +} diff --git a/src/DevBetterWeb.Core/Interfaces/IPaymentHandlerInvoice.cs b/src/DevBetterWeb.Core/Interfaces/IPaymentHandlerInvoice.cs index ace0376c7..93dd6d50b 100644 --- a/src/DevBetterWeb.Core/Interfaces/IPaymentHandlerInvoice.cs +++ b/src/DevBetterWeb.Core/Interfaces/IPaymentHandlerInvoice.cs @@ -1,4 +1,6 @@ -namespace DevBetterWeb.Core.Interfaces; +using DevBetterWeb.Core.ValueObjects; + +namespace DevBetterWeb.Core.Interfaces; public interface IPaymentHandlerInvoice { @@ -6,4 +8,5 @@ public interface IPaymentHandlerInvoice string GetBillingReason(string json); string GetCustomerId(string json); decimal GetPaymentAmount(string json); + PaidInvoiceDetails GetInvoiceDetails(string invoiceId); } diff --git a/src/DevBetterWeb.Core/Interfaces/IWebhookHandlerService.cs b/src/DevBetterWeb.Core/Interfaces/IWebhookHandlerService.cs index 2d61fa222..fc81489b9 100644 --- a/src/DevBetterWeb.Core/Interfaces/IWebhookHandlerService.cs +++ b/src/DevBetterWeb.Core/Interfaces/IWebhookHandlerService.cs @@ -8,4 +8,6 @@ public interface IWebhookHandlerService Task HandleCustomerSubscriptionRenewedAsync(string json); Task HandleCustomerSubscriptionEndedAsync(string json); Task HandleCustomerSubscriptionCancelledAtPeriodEndAsync(string json); + Task ReprocessSubscriptionEndedAsync(string subscriptionId); + Task ReprocessPaidInvoiceAsync(string invoiceId); } diff --git a/src/DevBetterWeb.Core/ValueObjects/PaidInvoiceDetails.cs b/src/DevBetterWeb.Core/ValueObjects/PaidInvoiceDetails.cs new file mode 100644 index 000000000..dbe099c41 --- /dev/null +++ b/src/DevBetterWeb.Core/ValueObjects/PaidInvoiceDetails.cs @@ -0,0 +1,3 @@ +namespace DevBetterWeb.Core.ValueObjects; + +public record PaidInvoiceDetails(string InvoiceId, string SubscriptionId, string BillingReason, string Status, decimal Total); diff --git a/src/DevBetterWeb.Infrastructure/PaymentHandler/StripePaymentHandler/StripePaymentHandlerInvoiceService.cs b/src/DevBetterWeb.Infrastructure/PaymentHandler/StripePaymentHandler/StripePaymentHandlerInvoiceService.cs index 69d971568..8d5f72de7 100644 --- a/src/DevBetterWeb.Infrastructure/PaymentHandler/StripePaymentHandler/StripePaymentHandlerInvoiceService.cs +++ b/src/DevBetterWeb.Infrastructure/PaymentHandler/StripePaymentHandler/StripePaymentHandlerInvoiceService.cs @@ -1,10 +1,18 @@ using DevBetterWeb.Core.Interfaces; +using DevBetterWeb.Core.ValueObjects; using Stripe; namespace DevBetterWeb.Infrastructure.PaymentHandler.StripePaymentHandler; public class StripePaymentHandlerInvoiceService : IPaymentHandlerInvoice { + private readonly InvoiceService _invoiceService; + + public StripePaymentHandlerInvoiceService(InvoiceService invoiceService) + { + _invoiceService = invoiceService; + } + public string GetBillingReason(string json) { var stripeEvent = EventUtility.ParseEvent(json); @@ -44,4 +52,16 @@ public string GetSubscriptionId(string json) return subscriptionId; } + + public PaidInvoiceDetails GetInvoiceDetails(string invoiceId) + { + var invoice = _invoiceService.Get(invoiceId); + + return new PaidInvoiceDetails( + invoice.Id, + invoice.Parent?.SubscriptionDetails?.SubscriptionId ?? string.Empty, + invoice.BillingReason, + invoice.Status, + invoice.Total); + } } diff --git a/src/DevBetterWeb.Infrastructure/Services/WebhookHandlerService.cs b/src/DevBetterWeb.Infrastructure/Services/WebhookHandlerService.cs index ffe894c43..cf8da1f1a 100644 --- a/src/DevBetterWeb.Infrastructure/Services/WebhookHandlerService.cs +++ b/src/DevBetterWeb.Infrastructure/Services/WebhookHandlerService.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using DevBetterWeb.Core; using DevBetterWeb.Core.Entities; using DevBetterWeb.Core.Exceptions; using DevBetterWeb.Core.Interfaces; @@ -79,7 +80,12 @@ public async Task HandleCustomerSubscriptionEndedAsync(string json) { // TODO: Log all JSON from these webhooks to a db table - perhaps use a decorator on this service var paymentHandlerEvent = _paymentHandlerEventService.FromJson(json); - var customerId = _paymentHandlerSubscription.GetCustomerId(paymentHandlerEvent.SubscriptionId); + await HandleCustomerSubscriptionEndedBySubscriptionIdAsync(paymentHandlerEvent.SubscriptionId); + } + + private async Task HandleCustomerSubscriptionEndedBySubscriptionIdAsync(string subscriptionId) + { + var customerId = _paymentHandlerSubscription.GetCustomerId(subscriptionId); var paymentHandlerCustomer = _paymentHandlerCustomerService.GetCustomer(customerId); if (await IsAlumniAsync(paymentHandlerCustomer.Email)) @@ -94,8 +100,8 @@ public async Task HandleCustomerSubscriptionEndedAsync(string json) var memberFullInfo = await _repository.FirstOrDefaultAsync(memberByEmailSpec); await _memberSubscriptionEndedAdminEmailService.SendMemberSubscriptionEndedEmailAsync(paymentHandlerCustomer.Email, memberFullInfo); - var subscriptionPlanName = _paymentHandlerSubscription.GetAssociatedProductName(paymentHandlerEvent.SubscriptionId); - var billingPeriod = _paymentHandlerSubscription.GetBillingPeriod(paymentHandlerEvent.SubscriptionId); + var subscriptionPlanName = _paymentHandlerSubscription.GetAssociatedProductName(subscriptionId); + var billingPeriod = _paymentHandlerSubscription.GetBillingPeriod(subscriptionId); await _memberAddBillingActivityService.AddMemberSubscriptionEndingBillingActivity(paymentHandlerCustomer.Email, subscriptionPlanName, billingPeriod); } @@ -103,7 +109,13 @@ public async Task HandleCustomerSubscriptionRenewedAsync(string json) { // TODO: Log all JSON from these webhooks to a db table - perhaps use a decorator on this service var paymentHandlerEvent = _paymentHandlerEventService.FromJson(json); - var customerId = _paymentHandlerSubscription.GetCustomerId(paymentHandlerEvent.SubscriptionId); + var paymentAmount = _paymentHandlerInvoice.GetPaymentAmount(json); + await HandleCustomerSubscriptionRenewedAsync(paymentHandlerEvent.SubscriptionId, paymentAmount); + } + + private async Task HandleCustomerSubscriptionRenewedAsync(string subscriptionId, decimal paymentAmount) + { + var customerId = _paymentHandlerSubscription.GetCustomerId(subscriptionId); var paymentHandlerCustomer = _paymentHandlerCustomerService.GetCustomer(customerId); if (await IsAlumniAsync(paymentHandlerCustomer.Email)) @@ -111,14 +123,12 @@ public async Task HandleCustomerSubscriptionRenewedAsync(string json) return; } - var subscriptionEndDate = _paymentHandlerSubscription.GetEndDate(paymentHandlerEvent.SubscriptionId); + var subscriptionEndDate = _paymentHandlerSubscription.GetEndDate(subscriptionId); await _memberSubscriptionRenewalService.ExtendMemberSubscription(paymentHandlerCustomer.Email, subscriptionEndDate); - var paymentAmount = _paymentHandlerInvoice.GetPaymentAmount(json); - - var subscriptionPlanName = _paymentHandlerSubscription.GetAssociatedProductName(paymentHandlerEvent.SubscriptionId); - var billingPeriod = _paymentHandlerSubscription.GetBillingPeriod(paymentHandlerEvent.SubscriptionId); + var subscriptionPlanName = _paymentHandlerSubscription.GetAssociatedProductName(subscriptionId); + var billingPeriod = _paymentHandlerSubscription.GetBillingPeriod(subscriptionId); await _memberAddBillingActivityService.AddMemberSubscriptionRenewalBillingActivity(paymentHandlerCustomer.Email, paymentAmount, subscriptionPlanName, billingPeriod); } @@ -131,23 +141,83 @@ public async Task HandleNewCustomerSubscriptionAsync(string json) _logger.LogWarning("Payment handler subscriptionId is null or empty", json); } var paymentAmount = _paymentHandlerInvoice.GetPaymentAmount(json); + await HandleNewCustomerSubscriptionAsync(paymentHandlerEvent.SubscriptionId, paymentAmount); + } + + /// + /// Re-runs invoice.paid processing for an invoice fetched from Stripe, for events that + /// could not be delivered to (or were rejected by) the invoice.paid webhook. + /// + public async Task ReprocessPaidInvoiceAsync(string invoiceId) + { + var invoice = _paymentHandlerInvoice.GetInvoiceDetails(invoiceId); - var newSubscriberIsAlreadyMember = await IsNewCustomerSubscriptionWithEmailAlreadyMember(paymentHandlerEvent.SubscriptionId); + if (invoice.Status != "paid") + { + return $"Invoice {invoiceId} was not processed because its status is '{invoice.Status}', not 'paid'."; + } + + if (string.IsNullOrEmpty(invoice.SubscriptionId)) + { + return $"Invoice {invoiceId} was not processed because it is not associated with a subscription."; + } + + if (invoice.BillingReason == StripeConstants.INVOICE_PAYMENT_SUCCEEDED_FOR_SUBSCRIPTION_CREATION) + { + var status = _paymentHandlerSubscription.GetStatus(invoice.SubscriptionId); + if (status != "active") + { + return $"Invoice {invoiceId} was not processed because subscription {invoice.SubscriptionId} status is '{status}', not 'active'."; + } + + await HandleNewCustomerSubscriptionAsync(invoice.SubscriptionId, invoice.Total); + return $"Invoice {invoiceId} processed as a new subscription ({invoice.SubscriptionId})."; + } + + if (invoice.BillingReason == StripeConstants.INVOICE_PAYMENT_SUCCEEDED_FOR_SUBSCRIPTION_RENEWAL) + { + await HandleCustomerSubscriptionRenewedAsync(invoice.SubscriptionId, invoice.Total); + return $"Invoice {invoiceId} processed as a subscription renewal ({invoice.SubscriptionId})."; + } + + return $"Invoice {invoiceId} was not processed because billing reason '{invoice.BillingReason}' is not handled."; + } + + /// + /// Re-runs customer.subscription.deleted processing for a subscription, for events that + /// could not be delivered to (or were rejected by) the subscription deleted webhook. + /// Refuses to run unless Stripe reports the subscription as canceled. + /// + public async Task ReprocessSubscriptionEndedAsync(string subscriptionId) + { + var status = _paymentHandlerSubscription.GetStatus(subscriptionId); + if (status != "canceled") + { + return $"Subscription {subscriptionId} was not processed because its status is '{status}', not 'canceled'."; + } + + await HandleCustomerSubscriptionEndedBySubscriptionIdAsync(subscriptionId); + return $"Subscription {subscriptionId} processed as ended."; + } + + private async Task HandleNewCustomerSubscriptionAsync(string subscriptionId, decimal paymentAmount) + { + var newSubscriberIsAlreadyMember = await IsNewCustomerSubscriptionWithEmailAlreadyMember(subscriptionId); if (newSubscriberIsAlreadyMember) { - _logger.LogInformation("New subscriber is an existing devBetter member", json); + _logger.LogInformation($"New subscriber on subscription {subscriptionId} is an existing devBetter member"); - await HandleNewCustomerSubscriptionWithEmailAlreadyMember(paymentHandlerEvent.SubscriptionId, paymentAmount); + await HandleNewCustomerSubscriptionWithEmailAlreadyMember(subscriptionId, paymentAmount); } else { - var status = _paymentHandlerSubscription.GetStatus(paymentHandlerEvent.SubscriptionId); + var status = _paymentHandlerSubscription.GetStatus(subscriptionId); _logger.LogInformation($"Subscription status: {status}"); if (status == "active") { - var customerId = _paymentHandlerSubscription.GetCustomerId(paymentHandlerEvent.SubscriptionId); + var customerId = _paymentHandlerSubscription.GetCustomerId(subscriptionId); var paymentHandlerCustomer = _paymentHandlerCustomerService.GetCustomer(customerId); if (string.IsNullOrEmpty(paymentHandlerCustomer.Email)) @@ -155,7 +225,7 @@ public async Task HandleNewCustomerSubscriptionAsync(string json) throw new InvalidEmailException(); } - Invitation invite = await _newMemberService.CreateInvitationAsync(paymentHandlerCustomer.Email, paymentHandlerEvent.SubscriptionId); + Invitation invite = await _newMemberService.CreateInvitationAsync(paymentHandlerCustomer.Email, subscriptionId); var webhookMessage = $"A new customer with email {paymentHandlerCustomer.Email} has subscribed to DevBetter. They will be receiving a registration email."; await _webhook.SendAsync($"Webhook:\n{webhookMessage}"); diff --git a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml index 482818856..151973508 100644 --- a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml +++ b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml @@ -12,6 +12,28 @@
@Model.StatusMessage
} +
+
+ Replay Missed Stripe Webhooks +
+
+

+ Use these when a Stripe webhook failed and the event cannot be resent (for example, events created with an older API version). + Each action runs the same processing as the webhook, so only replay an event once. +

+
+ + +
+
+ + +
+
+
+
Billable Stripe Subscriptions (@Model.Subscriptions.Count) diff --git a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs index eea47f6fe..1ebfb105e 100644 --- a/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs +++ b/src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using AutoMapper; using DevBetterWeb.Core; +using DevBetterWeb.Core.Interfaces; using DevBetterWeb.Infrastructure.Interfaces; using DevBetterWeb.Web.Models; using Microsoft.AspNetCore.Authorization; @@ -17,6 +18,7 @@ namespace DevBetterWeb.Web.Pages.Admin.ManageSubscriptions; public class IndexModel : PageModel { private readonly ISubscriptionHandlerService _subscriptionHandlerService; + private readonly IWebhookHandlerService _webhookHandlerService; private readonly IMapper _mapper; public List Subscriptions { get; private set; } = new(); @@ -24,9 +26,12 @@ public class IndexModel : PageModel [TempData] public string? StatusMessage { get; set; } - public IndexModel(ISubscriptionHandlerService subscriptionHandlerService, IMapper mapper) + public IndexModel(ISubscriptionHandlerService subscriptionHandlerService, + IWebhookHandlerService webhookHandlerService, + IMapper mapper) { _subscriptionHandlerService = subscriptionHandlerService; + _webhookHandlerService = webhookHandlerService; _mapper = mapper; } @@ -55,6 +60,34 @@ public Task OnPostCancelNowAsync(string subscriptionId) => ExecuteActionAsync(subscriptionId, id => _subscriptionHandlerService.CancelImmediatelyAsync(id, HttpContext.RequestAborted), "canceled immediately"); + // Replays invoice.paid processing (new member invitation or renewal) for events Stripe could not deliver. + public Task OnPostReplayPaidInvoiceAsync(string invoiceId) + => ExecuteReplayAsync(invoiceId, "invoice", () => _webhookHandlerService.ReprocessPaidInvoiceAsync(invoiceId.Trim())); + + // Replays customer.subscription.deleted processing (member role removal) for events Stripe could not deliver. + public Task OnPostReplaySubscriptionEndedAsync(string subscriptionId) + => ExecuteReplayAsync(subscriptionId, "subscription", () => _webhookHandlerService.ReprocessSubscriptionEndedAsync(subscriptionId.Trim())); + + private async Task ExecuteReplayAsync(string id, string idKind, Func> replay) + { + if (string.IsNullOrWhiteSpace(id)) + { + StatusMessage = $"No {idKind} id provided."; + return RedirectToPage(); + } + + try + { + StatusMessage = await replay(); + } + catch (Exception exception) + { + StatusMessage = $"Error replaying {idKind} {id}: {exception.Message}"; + } + + return RedirectToPage(); + } + private async Task ExecuteActionAsync(string subscriptionId, Func> action, string successVerb) { diff --git a/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs index 8705309ae..d383f06ad 100644 --- a/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs +++ b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/OnGetAsync.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using AutoMapper; +using DevBetterWeb.Core.Interfaces; using DevBetterWeb.Infrastructure.Interfaces; using DevBetterWeb.Web.MappingProfiles; using DevBetterWeb.Web.Pages.Admin.ManageSubscriptions; @@ -15,6 +16,7 @@ namespace DevBetterWeb.Tests.Pages.ManageSubscriptionsIndexModelTests; public class OnGetAsync { private readonly ISubscriptionHandlerService _subscriptionHandlerService = Substitute.For(); + private readonly IWebhookHandlerService _webhookHandlerService = Substitute.For(); private readonly IndexModel _pageModel; public OnGetAsync() @@ -22,7 +24,7 @@ public OnGetAsync() var configuration = new MapperConfiguration( cfg => cfg.AddProfile(), NullLoggerFactory.Instance); - _pageModel = new IndexModel(_subscriptionHandlerService, configuration.CreateMapper()); + _pageModel = new IndexModel(_subscriptionHandlerService, _webhookHandlerService, configuration.CreateMapper()); _pageModel.PageContext = new Microsoft.AspNetCore.Mvc.RazorPages.PageContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(), diff --git a/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/PostHandlers.cs b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/PostHandlers.cs index b10b18fcf..b656d4670 100644 --- a/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/PostHandlers.cs +++ b/tests/DevBetterWeb.Tests/Pages/ManageSubscriptionsIndexModelTests/PostHandlers.cs @@ -1,6 +1,7 @@ using System.Threading; using System.Threading.Tasks; using AutoMapper; +using DevBetterWeb.Core.Interfaces; using DevBetterWeb.Infrastructure.Interfaces; using DevBetterWeb.Web.MappingProfiles; using DevBetterWeb.Web.Pages.Admin.ManageSubscriptions; @@ -19,6 +20,7 @@ public class PostHandlers { private const string SubscriptionId = "sub_123"; private readonly ISubscriptionHandlerService _subscriptionHandlerService = Substitute.For(); + private readonly IWebhookHandlerService _webhookHandlerService = Substitute.For(); private readonly IndexModel _pageModel; public PostHandlers() @@ -26,7 +28,7 @@ public PostHandlers() var configuration = new MapperConfiguration( cfg => cfg.AddProfile(), NullLoggerFactory.Instance); - _pageModel = new IndexModel(_subscriptionHandlerService, configuration.CreateMapper()); + _pageModel = new IndexModel(_subscriptionHandlerService, _webhookHandlerService, configuration.CreateMapper()); _pageModel.PageContext = new PageContext { HttpContext = new DefaultHttpContext() }; } @@ -79,6 +81,51 @@ public async Task StripeErrorIsReportedInStatusMessageNotThrown() Assert.Contains("No such subscription", _pageModel.StatusMessage); } + [Fact] + public async Task ReplayPaidInvoiceReprocessesInvoiceAndReportsResult() + { + _webhookHandlerService.ReprocessPaidInvoiceAsync("in_123").Returns("Invoice in_123 processed as a new subscription (sub_123)."); + + var result = await _pageModel.OnPostReplayPaidInvoiceAsync(" in_123 "); + + await _webhookHandlerService.Received(1).ReprocessPaidInvoiceAsync("in_123"); + Assert.IsType(result); + Assert.Contains("processed as a new subscription", _pageModel.StatusMessage); + } + + [Fact] + public async Task ReplayPaidInvoiceErrorIsReportedInStatusMessageNotThrown() + { + _webhookHandlerService.ReprocessPaidInvoiceAsync("in_123") + .Returns>(_ => throw new StripeException("No such invoice")); + + var result = await _pageModel.OnPostReplayPaidInvoiceAsync("in_123"); + + Assert.IsType(result); + Assert.Contains("No such invoice", _pageModel.StatusMessage); + } + + [Fact] + public async Task ReplayPaidInvoiceRejectsMissingInvoiceId() + { + var result = await _pageModel.OnPostReplayPaidInvoiceAsync(""); + + await _webhookHandlerService.DidNotReceiveWithAnyArgs().ReprocessPaidInvoiceAsync(default!); + Assert.IsType(result); + } + + [Fact] + public async Task ReplaySubscriptionEndedProcessesSubscriptionAsEnded() + { + _webhookHandlerService.ReprocessSubscriptionEndedAsync(SubscriptionId).Returns($"Subscription {SubscriptionId} processed as ended."); + + var result = await _pageModel.OnPostReplaySubscriptionEndedAsync(SubscriptionId); + + await _webhookHandlerService.Received(1).ReprocessSubscriptionEndedAsync(SubscriptionId); + Assert.IsType(result); + Assert.Contains("processed as ended", _pageModel.StatusMessage); + } + [Fact] public async Task MissingSubscriptionIdIsRejectedWithoutCallingStripe() { diff --git a/tests/DevBetterWeb.Tests/Services/WebhookHandlerServiceTests/ReprocessPaidInvoiceAsync.cs b/tests/DevBetterWeb.Tests/Services/WebhookHandlerServiceTests/ReprocessPaidInvoiceAsync.cs new file mode 100644 index 000000000..1472123d4 --- /dev/null +++ b/tests/DevBetterWeb.Tests/Services/WebhookHandlerServiceTests/ReprocessPaidInvoiceAsync.cs @@ -0,0 +1,139 @@ +using System; +using System.Threading.Tasks; +using DevBetterWeb.Core; +using DevBetterWeb.Core.Entities; +using DevBetterWeb.Core.Interfaces; +using DevBetterWeb.Core.ValueObjects; +using DevBetterWeb.Infrastructure.DiscordWebooks; +using DevBetterWeb.Infrastructure.Interfaces; +using DevBetterWeb.Infrastructure.PaymentHandler; +using DevBetterWeb.Infrastructure.Services; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace DevBetterWeb.Tests.Services.WebhookHandlerServiceTests; + +public class ReprocessPaidInvoiceAsync +{ + private const string InvoiceId = "in_123"; + private const string SubscriptionId = "sub_123"; + private const string CustomerId = "cus_123"; + private const string Email = "new.member@example.com"; + + private readonly IPaymentHandlerSubscription _paymentHandlerSubscription = Substitute.For(); + private readonly IPaymentHandlerCustomerService _paymentHandlerCustomerService = Substitute.For(); + private readonly IPaymentHandlerInvoice _paymentHandlerInvoice = Substitute.For(); + private readonly INewMemberService _newMemberService = Substitute.For(); + private readonly IMemberSubscriptionRenewalService _memberSubscriptionRenewalService = Substitute.For(); + private readonly IMemberAddBillingActivityService _memberAddBillingActivityService = Substitute.For(); + private readonly IUserLookupService _userLookupService = Substitute.For(); + private readonly WebhookHandlerService _service; + + public ReprocessPaidInvoiceAsync() + { + _paymentHandlerSubscription.GetCustomerId(SubscriptionId).Returns(CustomerId); + _paymentHandlerSubscription.GetStatus(SubscriptionId).Returns("active"); + _paymentHandlerCustomerService.GetCustomer(CustomerId).Returns(new PaymentHandlerCustomer(CustomerId, Email)); + + var webhook = new AdminUpdatesWebhook(Substitute.For(), + Options.Create(new DiscordWebhookUrls { AdminUpdates = "https://example.com/admin-updates" })); + + _service = new WebhookHandlerService(_paymentHandlerSubscription, + _paymentHandlerCustomerService, + _paymentHandlerInvoice, + Substitute.For(), + _newMemberService, + _memberAddBillingActivityService, + _memberSubscriptionRenewalService, + Substitute.For(), + Substitute.For(), + _userLookupService, + Substitute.For>(), + webhook, + Substitute.For>()); + } + + private void GivenInvoice(string billingReason, string status = "paid", string subscriptionId = SubscriptionId) + { + _paymentHandlerInvoice.GetInvoiceDetails(InvoiceId) + .Returns(new PaidInvoiceDetails(InvoiceId, subscriptionId, billingReason, status, 20000)); + } + + [Fact] + public async Task CreatesInvitationAndSendsRegistrationEmailGivenNewSubscriptionInvoice() + { + GivenInvoice(StripeConstants.INVOICE_PAYMENT_SUCCEEDED_FOR_SUBSCRIPTION_CREATION); + var invite = new Invitation(Email, "invite-code", SubscriptionId); + _newMemberService.CreateInvitationAsync(Email, SubscriptionId).Returns(invite); + + var result = await _service.ReprocessPaidInvoiceAsync(InvoiceId); + + await _newMemberService.Received(1).CreateInvitationAsync(Email, SubscriptionId); + await _newMemberService.Received(1).SendRegistrationEmailAsync(invite); + Assert.Contains("new subscription", result); + } + + [Fact] + public async Task ExtendsMemberSubscriptionGivenRenewalInvoice() + { + GivenInvoice(StripeConstants.INVOICE_PAYMENT_SUCCEEDED_FOR_SUBSCRIPTION_RENEWAL); + var endDate = new DateTime(2026, 10, 20); + _paymentHandlerSubscription.GetEndDate(SubscriptionId).Returns(endDate); + _paymentHandlerSubscription.GetAssociatedProductName(SubscriptionId).Returns("Monthly Plan"); + + var result = await _service.ReprocessPaidInvoiceAsync(InvoiceId); + + await _memberSubscriptionRenewalService.Received(1).ExtendMemberSubscription(Email, endDate); + await _memberAddBillingActivityService.Received(1) + .AddMemberSubscriptionRenewalBillingActivity(Email, 20000, "Monthly Plan", Arg.Any()); + await _newMemberService.DidNotReceiveWithAnyArgs().CreateInvitationAsync(default!, default!); + Assert.Contains("renewal", result); + } + + [Fact] + public async Task DoesNothingGivenNewSubscriptionThatIsNotActive() + { + GivenInvoice(StripeConstants.INVOICE_PAYMENT_SUCCEEDED_FOR_SUBSCRIPTION_CREATION); + _paymentHandlerSubscription.GetStatus(SubscriptionId).Returns("canceled"); + + var result = await _service.ReprocessPaidInvoiceAsync(InvoiceId); + + await _newMemberService.DidNotReceiveWithAnyArgs().CreateInvitationAsync(default!, default!); + Assert.Contains("not processed", result); + } + + [Fact] + public async Task DoesNothingGivenUnpaidInvoice() + { + GivenInvoice(StripeConstants.INVOICE_PAYMENT_SUCCEEDED_FOR_SUBSCRIPTION_CREATION, status: "open"); + + var result = await _service.ReprocessPaidInvoiceAsync(InvoiceId); + + await _newMemberService.DidNotReceiveWithAnyArgs().CreateInvitationAsync(default!, default!); + Assert.Contains("not processed", result); + } + + [Fact] + public async Task DoesNothingGivenInvoiceWithoutSubscription() + { + GivenInvoice(StripeConstants.INVOICE_PAYMENT_SUCCEEDED_FOR_SUBSCRIPTION_CREATION, subscriptionId: ""); + + var result = await _service.ReprocessPaidInvoiceAsync(InvoiceId); + + await _newMemberService.DidNotReceiveWithAnyArgs().CreateInvitationAsync(default!, default!); + Assert.Contains("not processed", result); + } + + [Fact] + public async Task DoesNothingGivenUnhandledBillingReason() + { + GivenInvoice("manual"); + + var result = await _service.ReprocessPaidInvoiceAsync(InvoiceId); + + await _newMemberService.DidNotReceiveWithAnyArgs().CreateInvitationAsync(default!, default!); + await _memberSubscriptionRenewalService.DidNotReceiveWithAnyArgs().ExtendMemberSubscription(default!, default); + Assert.Contains("not handled", result); + } +} diff --git a/tests/DevBetterWeb.Tests/Services/WebhookHandlerServiceTests/ReprocessSubscriptionEndedAsync.cs b/tests/DevBetterWeb.Tests/Services/WebhookHandlerServiceTests/ReprocessSubscriptionEndedAsync.cs new file mode 100644 index 000000000..63ab38d7a --- /dev/null +++ b/tests/DevBetterWeb.Tests/Services/WebhookHandlerServiceTests/ReprocessSubscriptionEndedAsync.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using DevBetterWeb.Core.Entities; +using DevBetterWeb.Core.Interfaces; +using DevBetterWeb.Infrastructure.DiscordWebooks; +using DevBetterWeb.Infrastructure.Interfaces; +using DevBetterWeb.Infrastructure.PaymentHandler; +using DevBetterWeb.Infrastructure.Services; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace DevBetterWeb.Tests.Services.WebhookHandlerServiceTests; + +public class ReprocessSubscriptionEndedAsync +{ + private const string SubscriptionId = "sub_123"; + private const string CustomerId = "cus_123"; + private const string Email = "ending.member@example.com"; + + private readonly IPaymentHandlerSubscription _paymentHandlerSubscription = Substitute.For(); + private readonly IPaymentHandlerCustomerService _paymentHandlerCustomerService = Substitute.For(); + private readonly IMemberCancellationService _memberCancellationService = Substitute.For(); + private readonly WebhookHandlerService _service; + + public ReprocessSubscriptionEndedAsync() + { + _paymentHandlerSubscription.GetCustomerId(SubscriptionId).Returns(CustomerId); + _paymentHandlerCustomerService.GetCustomer(CustomerId).Returns(new PaymentHandlerCustomer(CustomerId, Email)); + + var webhook = new AdminUpdatesWebhook(Substitute.For(), + Options.Create(new DiscordWebhookUrls { AdminUpdates = "https://example.com/admin-updates" })); + + _service = new WebhookHandlerService(_paymentHandlerSubscription, + _paymentHandlerCustomerService, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + _memberCancellationService, + Substitute.For(), + Substitute.For(), + Substitute.For>(), + webhook, + Substitute.For>()); + } + + [Fact] + public async Task RemovesMemberRoleGivenCanceledSubscription() + { + _paymentHandlerSubscription.GetStatus(SubscriptionId).Returns("canceled"); + + var result = await _service.ReprocessSubscriptionEndedAsync(SubscriptionId); + + await _memberCancellationService.Received(1).RemoveUserFromMemberRoleAsync(Email); + Assert.Contains("processed as ended", result); + } + + [Fact] + public async Task DoesNothingGivenActiveSubscription() + { + _paymentHandlerSubscription.GetStatus(SubscriptionId).Returns("active"); + + var result = await _service.ReprocessSubscriptionEndedAsync(SubscriptionId); + + await _memberCancellationService.DidNotReceiveWithAnyArgs().RemoveUserFromMemberRoleAsync(default!); + Assert.Contains("not processed", result); + } +}