Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .tools/nimble-metrics/README.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions .tools/nimble-metrics/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"rebuildHistory": {
"enabled": false,
"maxTotalDataPoints": 25,
"minimumTimeGranularity": "days"
},
"concerns": {
"enabled": false,
"regionRetention": 0
},
"filters": {
"exclude": [
"**/wwwroot/**"
]
}
}
5 changes: 4 additions & 1 deletion src/DevBetterWeb.Core/Interfaces/IPaymentHandlerInvoice.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
namespace DevBetterWeb.Core.Interfaces;
using DevBetterWeb.Core.ValueObjects;

namespace DevBetterWeb.Core.Interfaces;

public interface IPaymentHandlerInvoice
{
string GetSubscriptionId(string json);
string GetBillingReason(string json);
string GetCustomerId(string json);
decimal GetPaymentAmount(string json);
PaidInvoiceDetails GetInvoiceDetails(string invoiceId);
}
2 changes: 2 additions & 0 deletions src/DevBetterWeb.Core/Interfaces/IWebhookHandlerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ public interface IWebhookHandlerService
Task HandleCustomerSubscriptionRenewedAsync(string json);
Task HandleCustomerSubscriptionEndedAsync(string json);
Task HandleCustomerSubscriptionCancelledAtPeriodEndAsync(string json);
Task<string> ReprocessSubscriptionEndedAsync(string subscriptionId);
Task<string> ReprocessPaidInvoiceAsync(string invoiceId);
}
3 changes: 3 additions & 0 deletions src/DevBetterWeb.Core/ValueObjects/PaidInvoiceDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace DevBetterWeb.Core.ValueObjects;

public record PaidInvoiceDetails(string InvoiceId, string SubscriptionId, string BillingReason, string Status, decimal Total);
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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);
}
}
100 changes: 85 additions & 15 deletions src/DevBetterWeb.Infrastructure/Services/WebhookHandlerService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using DevBetterWeb.Core;
using DevBetterWeb.Core.Entities;
using DevBetterWeb.Core.Exceptions;
using DevBetterWeb.Core.Interfaces;
Expand Down Expand Up @@ -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))
Expand All @@ -94,31 +100,35 @@ 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);
}

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))
{
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);
}

Expand All @@ -131,31 +141,91 @@ 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);
}

/// <summary>
/// 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.
/// </summary>
public async Task<string> 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.";
}

/// <summary>
/// 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.
/// </summary>
public async Task<string> 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))
{
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}");
Expand Down
22 changes: 22 additions & 0 deletions src/DevBetterWeb.Web/Pages/Admin/ManageSubscriptions/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@
<div class="alert alert-info">@Model.StatusMessage</div>
}

<div class="card mb-3">
<div class="card-header">
<b>Replay Missed Stripe Webhooks</b>
</div>
<div class="card-body">
<p class="text-muted">
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.
</p>
<form method="post" asp-page-handler="ReplayPaidInvoice" class="form-inline mb-2"
onsubmit="return confirm('Process this paid invoice? A new subscription sends a registration email; a renewal extends the member and records billing activity.');">
<input type="text" name="invoiceId" class="form-control mr-2" placeholder="in_..." style="min-width: 22rem;" />
<button type="submit" class="btn btn-primary">Replay invoice.paid</button>
</form>
<form method="post" asp-page-handler="ReplaySubscriptionEnded" class="form-inline"
onsubmit="return confirm('Process this subscription as ended? This removes the member role and sends a cancellation email.');">
<input type="text" name="subscriptionId" class="form-control mr-2" placeholder="sub_..." style="min-width: 22rem;" />
<button type="submit" class="btn btn-danger">Replay subscription ended</button>
</form>
</div>
</div>

<div class="card">
<div class="card-header">
<b>Billable Stripe Subscriptions (@Model.Subscriptions.Count)</b>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,16 +18,20 @@
public class IndexModel : PageModel
{
private readonly ISubscriptionHandlerService _subscriptionHandlerService;
private readonly IWebhookHandlerService _webhookHandlerService;
private readonly IMapper _mapper;

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

[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;
}

Expand Down Expand Up @@ -55,6 +60,34 @@
=> 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<IActionResult> 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<IActionResult> OnPostReplaySubscriptionEndedAsync(string subscriptionId)
=> ExecuteReplayAsync(subscriptionId, "subscription", () => _webhookHandlerService.ReprocessSubscriptionEndedAsync(subscriptionId.Trim()));

private async Task<IActionResult> ExecuteReplayAsync(string id, string idKind, Func<Task<string>> 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}";
}
Comment thread
ardalis marked this conversation as resolved.
Dismissed

return RedirectToPage();
}

private async Task<IActionResult> ExecuteActionAsync(string subscriptionId,
Func<string, Task<Subscription>> action, string successVerb)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,14 +16,15 @@ namespace DevBetterWeb.Tests.Pages.ManageSubscriptionsIndexModelTests;
public class OnGetAsync
{
private readonly ISubscriptionHandlerService _subscriptionHandlerService = Substitute.For<ISubscriptionHandlerService>();
private readonly IWebhookHandlerService _webhookHandlerService = Substitute.For<IWebhookHandlerService>();
private readonly IndexModel _pageModel;

public OnGetAsync()
{
var configuration = new MapperConfiguration(
cfg => cfg.AddProfile<SubscriptionProfile>(),
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(),
Expand Down
Loading
Loading