1
0
mirror of https://github.com/bitwarden/server synced 2026-02-26 01:13:35 +00:00

[PM-30275] stripe coupon deleted handler (#7073)

* [PM-30275] stripe coupon deleted handler

* will commit this in a different PR
This commit is contained in:
Kyle Denney
2026-02-25 09:06:51 -06:00
committed by GitHub
parent 5ac8293a55
commit 5b20ee9184
8 changed files with 149 additions and 1 deletions

View File

@@ -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";
}

View File

@@ -67,3 +67,8 @@ public interface ICustomerUpdatedHandler : IStripeWebhookHandler;
public interface IInvoiceFinalizedHandler : IStripeWebhookHandler;
public interface ISetupIntentSucceededHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe coupon deleted events.
/// </summary>
public interface ICouponDeletedHandler : IStripeWebhookHandler;

View File

@@ -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<CouponDeletedHandler> 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);
}
}

View File

@@ -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;

View File

@@ -84,6 +84,11 @@ public class StripeEventService(
public async Task<bool> ValidateCloudRegion(Event stripeEvent)
{
if (EventTypeAppliesToAllRegions(stripeEvent.Type))
{
return true;
}
var serverRegion = globalSettings.BaseServiceUri.CloudRegion;
var customerExpansion = new List<string> { "customer" };
@@ -139,6 +144,16 @@ public class StripeEventService(
}
}
/// <summary>
/// Returns true for event types that should be processed by all cloud regions.
/// </summary>
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<T>(Event stripeEvent)
=> stripeEvent.Data.Object is not T type
? throw new Exception(

View File

@@ -70,6 +70,7 @@ public class Startup
services.AddScoped<IPaymentSucceededHandler, PaymentSucceededHandler>();
services.AddScoped<IInvoiceFinalizedHandler, InvoiceFinalizedHandler>();
services.AddScoped<ISetupIntentSucceededHandler, SetupIntentSucceededHandler>();
services.AddScoped<ICouponDeletedHandler, CouponDeletedHandler>();
services.AddScoped<IStripeEventProcessor, StripeEventProcessor>();
// Identity

View File

@@ -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<CouponDeletedHandler> _logger = Substitute.For<ILogger<CouponDeletedHandler>>();
private readonly ISubscriptionDiscountRepository _subscriptionDiscountRepository = Substitute.For<ISubscriptionDiscountRepository>();
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);
}
}

View File

@@ -689,6 +689,26 @@ public class StripeEventServiceTests
mockSetupIntent.Id,
Arg.Any<SetupIntentGetOptions>());
}
[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<T>(string id, string type, T dataObject) where T : IStripeEntity