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
11 changes: 9 additions & 2 deletions src/Exceptionless.Core/Billing/StripeEventHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public async Task HandleEventAsync(Stripe.Event stripeEvent)
}
case "customer.subscription.deleted":
{
await SubscriptionDeletedAsync((Subscription)stripeEvent.Data.Object);
await SubscriptionDeletedAsync((Subscription)stripeEvent.Data.Object, stripeEvent.Created);
break;
}
case "invoice.payment_succeeded":
Expand Down Expand Up @@ -121,7 +121,7 @@ private async Task SubscriptionUpdatedAsync(Subscription sub)
await _organizationRepository.SaveAsync(org, o => o.Cache().Originals());
}

private async Task SubscriptionDeletedAsync(Subscription sub)
private async Task SubscriptionDeletedAsync(Subscription sub, DateTime eventCreatedUtc)
{
var org = await _organizationRepository.GetByStripeCustomerIdAsync(sub.CustomerId);
if (org is null)
Expand All @@ -132,6 +132,13 @@ private async Task SubscriptionDeletedAsync(Subscription sub)

_logger.LogInformation("Stripe subscription deleted. Customer: {CustomerId} Org: {Organization} Org Name: {OrganizationName}", sub.CustomerId, org.Id, org.Name);

if (org.BillingChangeDate > DateTime.MinValue && eventCreatedUtc < org.BillingChangeDate)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compare deletions against a Stripe event watermark

When Stripe generates an earlier customer.subscription.updated event and a later deletion before either webhook is delivered, processing the update first sets BillingChangeDate to the current handler time; the deletion's creation time is then earlier than that value and this branch acknowledges but discards the genuinely newer deletion. Because Stripe does not guarantee immediate delivery, the organization can remain active or past due after its subscription was canceled. Track the latest Stripe source-event timestamp separately, or otherwise distinguish local plan transitions from webhook receipt times, and cover queued/out-of-order events.

AGENTS.md reference: AGENTS.md:L74-L74

Useful? React with 👍 / 👎.

{
_logger.LogInformation("Ignoring stale Stripe subscription deletion. Customer: {CustomerId} Org: {Organization} Event Created: {EventCreatedUtc} Billing Changed: {BillingChangeDate}",
sub.CustomerId, org.Id, eventCreatedUtc, org.BillingChangeDate);
return;
}

var utcNow = _timeProvider.GetUtcNow().UtcDateTime;
org.BillingChangeDate = utcNow;
org.BillingStatus = BillingStatus.Canceled;
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ public async Task<Result<ChangePlanResult>> Handle(ChangeOrganizationPlan messag
{
var subs = await stripeBillingClient.ListSubscriptionsAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId });
foreach (var sub in subs.Where(s => !s.CanceledAt.HasValue))
await stripeBillingClient.CancelSubscriptionAsync(sub.Id, new SubscriptionCancelOptions());
await stripeBillingClient.CancelSubscriptionAsync(sub.Id, new SubscriptionCancelOptions { Prorate = true, InvoiceNow = true });
}

organization.BillingStatus = BillingStatus.Trialing;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1477,7 +1477,10 @@ public async Task ChangePlanAsync_FreePlanCancelsActiveStripeSubscriptions()
Assert.NotNull(result);
Assert.True(result.Success);
Assert.Equal("cus_existing", StripeBillingClient.LastSubscriptionListOptions?.Customer);
Assert.Equal("sub_active", Assert.Single(StripeBillingClient.CanceledSubscriptions).SubscriptionId);
var canceledSubscription = Assert.Single(StripeBillingClient.CanceledSubscriptions);
Assert.Equal("sub_active", canceledSubscription.SubscriptionId);
Assert.True(canceledSubscription.Options.Prorate);
Assert.True(canceledSubscription.Options.InvoiceNow);

var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID);
Assert.NotNull(organization);
Expand Down
82 changes: 81 additions & 1 deletion tests/Exceptionless.Tests/Api/Endpoints/StripeEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Exceptionless.Core;
using Exceptionless.Core.Extensions;
using Exceptionless.Core.Models;
using Exceptionless.Core.Repositories;
using Exceptionless.Core.Utility;
using Exceptionless.Tests.Extensions;
using FluentRest;
using Foundatio.Repositories;
using Xunit;

namespace Exceptionless.Tests.Api.Endpoints;

public class StripeEndpointTests : IntegrationTestsBase
{
public StripeEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { }
private const string WebhookSigningSecret = "whsec_local_test";
private readonly IOrganizationRepository _organizationRepository;

public StripeEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory)
{
_organizationRepository = GetService<IOrganizationRepository>();
}

protected override async Task ResetDataAsync()
{
Expand Down Expand Up @@ -85,4 +97,72 @@ public async Task PostAsync_WithNonJsonContentType_ReturnsUnsupportedMediaType()
// Assert
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
}

[Theory]
[InlineData(BillingStatus.Trialing)]
[InlineData(BillingStatus.Active)]
public async Task PostAsync_WithStaleSubscriptionDeletedEvent_DoesNotOverwriteNewerBillingState(BillingStatus billingStatus)
{
// Arrange
var eventCreatedUtc = new DateTime(2026, 6, 22, 19, 3, 23, DateTimeKind.Utc);
var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID);
Assert.NotNull(organization);
organization.StripeCustomerId = "cus_existing";
organization.BillingChangeDate = eventCreatedUtc.AddSeconds(20);
organization.BillingStatus = billingStatus;
organization.RemoveSuspension();
await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency());

/* language=json */
const string json = $$"""
{
"id": "evt_subscription_deleted",
"object": "event",
"created": 1782155003,
"data": {
"object": {
"id": "sub_old",
"object": "subscription",
"customer": "cus_existing",
"status": "canceled"
}
},
"livemode": false,
"pending_webhooks": 1,
"type": "customer.subscription.deleted"
}
""";

long signatureTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
byte[] signatureBytes = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(WebhookSigningSecret),
Encoding.UTF8.GetBytes($"{signatureTimestamp}.{json}")
);

var options = GetService<AppOptions>();
string? originalSigningSecret = options.StripeOptions.StripeWebHookSigningSecret;
options.StripeOptions.StripeWebHookSigningSecret = WebhookSigningSecret;
try
{
// Act
using var content = new StringContent(json, Encoding.UTF8, "application/json");
await SendRequestAsync(r => r
.Post()
.AppendPath("stripe")
.Content(content)
.Header("Stripe-Signature", $"t={signatureTimestamp},v1={Convert.ToHexStringLower(signatureBytes)}")
.StatusCodeShouldBeOk()
);
}
finally
{
options.StripeOptions.StripeWebHookSigningSecret = originalSigningSecret;
}

// Assert
organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID, o => o.Cache(false));
Assert.NotNull(organization);
Assert.Equal(billingStatus, organization.BillingStatus);
Assert.False(organization.IsSuspended);
}
}
Loading