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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
30
src/Billing/Services/Implementations/CouponDeletedHandler.cs
Normal file
30
src/Billing/Services/Implementations/CouponDeletedHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
72
test/Billing.Test/Services/CouponDeletedHandlerTests.cs
Normal file
72
test/Billing.Test/Services/CouponDeletedHandlerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user