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..2a8c59c38b 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,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(); + 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); + } }