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