From fccb5de25bb4038560499f156aa50b965c45d185 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 28 Jul 2026 21:49:21 -0500 Subject: [PATCH 1/2] Fix billing downgrade proration race --- .../Billing/StripeEventHandler.cs | 11 ++- .../Api/Handlers/OrganizationHandler.cs | 2 +- .../Endpoints/OrganizationEndpointTests.cs | 5 +- .../Api/Endpoints/StripeEndpointTests.cs | 90 ++++++++++++++++++- 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/Exceptionless.Core/Billing/StripeEventHandler.cs b/src/Exceptionless.Core/Billing/StripeEventHandler.cs index 01dd40d2b7..e0b526146f 100644 --- a/src/Exceptionless.Core/Billing/StripeEventHandler.cs +++ b/src/Exceptionless.Core/Billing/StripeEventHandler.cs @@ -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": @@ -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) @@ -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) + { + _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; diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 9c910c721c..17c292759f 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -398,7 +398,7 @@ public async Task> 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; diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 4fddfae77a..45fd428e6e 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -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); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/StripeEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/StripeEndpointTests.cs index 3c8d2a82c4..daecb9993f 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/StripeEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/StripeEndpointTests.cs @@ -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(); + } protected override async Task ResetDataAsync() { @@ -85,4 +97,80 @@ 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) + { + var eventCreatedUtc = new DateTime(2026, 6, 22, 19, 3, 23, DateTimeKind.Utc); + await SetBillingStateAsync(eventCreatedUtc.AddSeconds(20), billingStatus); + + await PostSubscriptionDeletedWebhookAsync(eventCreatedUtc); + + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID, o => o.Cache(false)); + Assert.NotNull(organization); + Assert.Equal(billingStatus, organization.BillingStatus); + Assert.False(organization.IsSuspended); + } + + private async Task SetBillingStateAsync(DateTime billingChangeDate, BillingStatus billingStatus) + { + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); + Assert.NotNull(organization); + + organization.StripeCustomerId = "cus_existing"; + organization.BillingChangeDate = billingChangeDate; + organization.BillingStatus = billingStatus; + organization.RemoveSuspension(); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + } + + private async Task PostSubscriptionDeletedWebhookAsync(DateTime eventCreatedUtc) + { + long eventTimestamp = new DateTimeOffset(eventCreatedUtc).ToUnixTimeSeconds(); + string json = $$""" + { + "id": "evt_subscription_deleted", + "object": "event", + "created": {{eventTimestamp}}, + "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(); + string? originalSigningSecret = options.StripeOptions.StripeWebHookSigningSecret; + options.StripeOptions.StripeWebHookSigningSecret = WebhookSigningSecret; + try + { + 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; + } + } } From fdbe4fc37d2c401facd5e0f08f5832de819bcbda Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 12:03:40 -0500 Subject: [PATCH 2/2] Clarify stale billing webhook test --- .../Api/Endpoints/StripeEndpointTests.cs | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/StripeEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/StripeEndpointTests.cs index daecb9993f..2a8c59c38b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/StripeEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/StripeEndpointTests.cs @@ -103,37 +103,22 @@ public async Task PostAsync_WithNonJsonContentType_ReturnsUnsupportedMediaType() [InlineData(BillingStatus.Active)] public async Task PostAsync_WithStaleSubscriptionDeletedEvent_DoesNotOverwriteNewerBillingState(BillingStatus billingStatus) { + // Arrange var eventCreatedUtc = new DateTime(2026, 6, 22, 19, 3, 23, DateTimeKind.Utc); - await SetBillingStateAsync(eventCreatedUtc.AddSeconds(20), billingStatus); - - await PostSubscriptionDeletedWebhookAsync(eventCreatedUtc); - - var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID, o => o.Cache(false)); - Assert.NotNull(organization); - Assert.Equal(billingStatus, organization.BillingStatus); - Assert.False(organization.IsSuspended); - } - - private async Task SetBillingStateAsync(DateTime billingChangeDate, BillingStatus billingStatus) - { var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); Assert.NotNull(organization); - organization.StripeCustomerId = "cus_existing"; - organization.BillingChangeDate = billingChangeDate; + organization.BillingChangeDate = eventCreatedUtc.AddSeconds(20); organization.BillingStatus = billingStatus; organization.RemoveSuspension(); await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - } - private async Task PostSubscriptionDeletedWebhookAsync(DateTime eventCreatedUtc) - { - long eventTimestamp = new DateTimeOffset(eventCreatedUtc).ToUnixTimeSeconds(); - string json = $$""" + /* language=json */ + const string json = $$""" { "id": "evt_subscription_deleted", "object": "event", - "created": {{eventTimestamp}}, + "created": 1782155003, "data": { "object": { "id": "sub_old", @@ -159,6 +144,7 @@ private async Task PostSubscriptionDeletedWebhookAsync(DateTime eventCreatedUtc) options.StripeOptions.StripeWebHookSigningSecret = WebhookSigningSecret; try { + // Act using var content = new StringContent(json, Encoding.UTF8, "application/json"); await SendRequestAsync(r => r .Post() @@ -172,5 +158,11 @@ await SendRequestAsync(r => r { 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); } }