From 5b20ee9184b0164ee22c534116876be7aa8c5496 Mon Sep 17 00:00:00 2001
From: Kyle Denney <4227399+kdenney@users.noreply.github.com>
Date: Wed, 25 Feb 2026 09:06:51 -0600
Subject: [PATCH] [PM-30275] stripe coupon deleted handler (#7073)
* [PM-30275] stripe coupon deleted handler
* will commit this in a different PR
---
src/Billing/Constants/HandledStripeWebhook.cs | 1 +
src/Billing/Services/IStripeWebhookHandler.cs | 5 ++
.../Implementations/CouponDeletedHandler.cs | 30 ++++++++
.../Implementations/StripeEventProcessor.cs | 6 +-
.../Implementations/StripeEventService.cs | 15 ++++
src/Billing/Startup.cs | 1 +
.../Services/CouponDeletedHandlerTests.cs | 72 +++++++++++++++++++
.../Services/StripeEventServiceTests.cs | 20 ++++++
8 files changed, 149 insertions(+), 1 deletion(-)
create mode 100644 src/Billing/Services/Implementations/CouponDeletedHandler.cs
create mode 100644 test/Billing.Test/Services/CouponDeletedHandlerTests.cs
diff --git a/src/Billing/Constants/HandledStripeWebhook.cs b/src/Billing/Constants/HandledStripeWebhook.cs
index e9e0c5a16b..6e1274bbaa 100644
--- a/src/Billing/Constants/HandledStripeWebhook.cs
+++ b/src/Billing/Constants/HandledStripeWebhook.cs
@@ -14,4 +14,5 @@ public static class HandledStripeWebhook
public const string CustomerUpdated = "customer.updated";
public const string InvoiceFinalized = "invoice.finalized";
public const string SetupIntentSucceeded = "setup_intent.succeeded";
+ public const string CouponDeleted = "coupon.deleted";
}
diff --git a/src/Billing/Services/IStripeWebhookHandler.cs b/src/Billing/Services/IStripeWebhookHandler.cs
index 2619b2f663..3bc32f37f5 100644
--- a/src/Billing/Services/IStripeWebhookHandler.cs
+++ b/src/Billing/Services/IStripeWebhookHandler.cs
@@ -67,3 +67,8 @@ public interface ICustomerUpdatedHandler : IStripeWebhookHandler;
public interface IInvoiceFinalizedHandler : IStripeWebhookHandler;
public interface ISetupIntentSucceededHandler : IStripeWebhookHandler;
+
+///
+/// Defines the contract for handling Stripe coupon deleted events.
+///
+public interface ICouponDeletedHandler : IStripeWebhookHandler;
diff --git a/src/Billing/Services/Implementations/CouponDeletedHandler.cs b/src/Billing/Services/Implementations/CouponDeletedHandler.cs
new file mode 100644
index 0000000000..5561ece703
--- /dev/null
+++ b/src/Billing/Services/Implementations/CouponDeletedHandler.cs
@@ -0,0 +1,30 @@
+using Bit.Core.Billing.Subscriptions.Repositories;
+using Stripe;
+using Event = Stripe.Event;
+
+namespace Bit.Billing.Services.Implementations;
+
+public class CouponDeletedHandler(
+ ILogger logger,
+ ISubscriptionDiscountRepository subscriptionDiscountRepository) : ICouponDeletedHandler
+{
+ public async Task HandleAsync(Event parsedEvent)
+ {
+ if (parsedEvent.Data.Object is not Coupon coupon)
+ {
+ logger.LogWarning("Received coupon.deleted event with unexpected object type. Event ID: {EventId}", parsedEvent.Id);
+ return;
+ }
+
+ var discount = await subscriptionDiscountRepository.GetByStripeCouponIdAsync(coupon.Id);
+
+ if (discount is null)
+ {
+ logger.LogInformation("Received coupon.deleted event for coupon {CouponId} not found in database. Ignoring.", coupon.Id);
+ return;
+ }
+
+ await subscriptionDiscountRepository.DeleteAsync(discount);
+ logger.LogInformation("Deleted subscription discount for Stripe coupon {CouponId}.", coupon.Id);
+ }
+}
diff --git a/src/Billing/Services/Implementations/StripeEventProcessor.cs b/src/Billing/Services/Implementations/StripeEventProcessor.cs
index 6db813f70c..6c95086ab0 100644
--- a/src/Billing/Services/Implementations/StripeEventProcessor.cs
+++ b/src/Billing/Services/Implementations/StripeEventProcessor.cs
@@ -16,7 +16,8 @@ public class StripeEventProcessor(
IPaymentMethodAttachedHandler paymentMethodAttachedHandler,
ICustomerUpdatedHandler customerUpdatedHandler,
IInvoiceFinalizedHandler invoiceFinalizedHandler,
- ISetupIntentSucceededHandler setupIntentSucceededHandler)
+ ISetupIntentSucceededHandler setupIntentSucceededHandler,
+ ICouponDeletedHandler couponDeletedHandler)
: IStripeEventProcessor
{
public async Task ProcessEventAsync(Event parsedEvent)
@@ -59,6 +60,9 @@ public class StripeEventProcessor(
case HandledStripeWebhook.SetupIntentSucceeded:
await setupIntentSucceededHandler.HandleAsync(parsedEvent);
break;
+ case HandledStripeWebhook.CouponDeleted:
+ await couponDeletedHandler.HandleAsync(parsedEvent);
+ break;
default:
logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
break;
diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs
index 73ee028b21..914ea05931 100644
--- a/src/Billing/Services/Implementations/StripeEventService.cs
+++ b/src/Billing/Services/Implementations/StripeEventService.cs
@@ -84,6 +84,11 @@ public class StripeEventService(
public async Task ValidateCloudRegion(Event stripeEvent)
{
+ if (EventTypeAppliesToAllRegions(stripeEvent.Type))
+ {
+ return true;
+ }
+
var serverRegion = globalSettings.BaseServiceUri.CloudRegion;
var customerExpansion = new List { "customer" };
@@ -139,6 +144,16 @@ public class StripeEventService(
}
}
+ ///
+ /// Returns true for event types that should be processed by all cloud regions.
+ ///
+ private static bool EventTypeAppliesToAllRegions(string eventType) => eventType switch
+ {
+ // Business rules say that coupons are allowed to be imported into multiple regions, so coupon deleted events are not region-segmented
+ HandledStripeWebhook.CouponDeleted => true,
+ _ => false
+ };
+
private static T Extract(Event stripeEvent)
=> stripeEvent.Data.Object is not T type
? throw new Exception(
diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs
index f5f98bfd53..c21727f4cf 100644
--- a/src/Billing/Startup.cs
+++ b/src/Billing/Startup.cs
@@ -70,6 +70,7 @@ public class Startup
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
// Identity
diff --git a/test/Billing.Test/Services/CouponDeletedHandlerTests.cs b/test/Billing.Test/Services/CouponDeletedHandlerTests.cs
new file mode 100644
index 0000000000..b55872edd2
--- /dev/null
+++ b/test/Billing.Test/Services/CouponDeletedHandlerTests.cs
@@ -0,0 +1,72 @@
+using Bit.Billing.Services.Implementations;
+using Bit.Core.Billing.Subscriptions.Entities;
+using Bit.Core.Billing.Subscriptions.Repositories;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Stripe;
+using Xunit;
+
+namespace Bit.Billing.Test.Services;
+
+public class CouponDeletedHandlerTests
+{
+ private readonly ILogger _logger = Substitute.For>();
+ private readonly ISubscriptionDiscountRepository _subscriptionDiscountRepository = Substitute.For();
+ private readonly CouponDeletedHandler _sut;
+
+ public CouponDeletedHandlerTests()
+ {
+ _sut = new CouponDeletedHandler(_logger, _subscriptionDiscountRepository);
+ }
+
+ [Fact]
+ public async Task HandleAsync_EventObjectNotCoupon_ReturnsWithoutDeleting()
+ {
+ // Arrange
+ var stripeEvent = new Event
+ {
+ Id = "evt_test",
+ Data = new EventData { Object = new Customer { Id = "cus_unexpected" } }
+ };
+
+ // Act
+ await _sut.HandleAsync(stripeEvent);
+
+ // Assert
+ await _subscriptionDiscountRepository.DidNotReceiveWithAnyArgs()
+ .GetByStripeCouponIdAsync(null!);
+ await _subscriptionDiscountRepository.DidNotReceiveWithAnyArgs()
+ .DeleteAsync(null!);
+ }
+
+ [Fact]
+ public async Task HandleAsync_CouponNotInDatabase_DoesNotDeleteAnything()
+ {
+ // Arrange
+ var stripeEvent = new Event { Data = new EventData { Object = new Coupon { Id = "cou_test" } } };
+
+ _subscriptionDiscountRepository.GetByStripeCouponIdAsync("cou_test").Returns((SubscriptionDiscount?)null);
+
+ // Act
+ await _sut.HandleAsync(stripeEvent);
+
+ // Assert
+ await _subscriptionDiscountRepository.DidNotReceiveWithAnyArgs().DeleteAsync(null!);
+ }
+
+ [Fact]
+ public async Task HandleAsync_CouponExistsInDatabase_DeletesDiscount()
+ {
+ // Arrange
+ var stripeEvent = new Event { Data = new EventData { Object = new Coupon { Id = "cou_test" } } };
+
+ var discount = new SubscriptionDiscount { StripeCouponId = "cou_test" };
+ _subscriptionDiscountRepository.GetByStripeCouponIdAsync("cou_test").Returns(discount);
+
+ // Act
+ await _sut.HandleAsync(stripeEvent);
+
+ // Assert
+ await _subscriptionDiscountRepository.Received(1).DeleteAsync(discount);
+ }
+}
diff --git a/test/Billing.Test/Services/StripeEventServiceTests.cs b/test/Billing.Test/Services/StripeEventServiceTests.cs
index cb62f146e7..1941d12ecd 100644
--- a/test/Billing.Test/Services/StripeEventServiceTests.cs
+++ b/test/Billing.Test/Services/StripeEventServiceTests.cs
@@ -689,6 +689,26 @@ public class StripeEventServiceTests
mockSetupIntent.Id,
Arg.Any());
}
+
+ [Fact]
+ public async Task ValidateCloudRegion_CouponDeleted_ReturnsTrue()
+ {
+ // Arrange
+ var stripeEvent = CreateMockEvent("evt_test", "coupon.deleted", new Coupon { Id = "cou_test" });
+
+ // Act
+ var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
+
+ // Assert
+ Assert.True(cloudRegionValid);
+
+ await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(null);
+ await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge(null);
+ await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice(null);
+ await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod(null);
+ await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer(null);
+ await _stripeFacade.DidNotReceiveWithAnyArgs().GetSetupIntent(null);
+ }
#endregion
private static Event CreateMockEvent(string id, string type, T dataObject) where T : IStripeEntity