diff --git a/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs new file mode 100644 index 0000000000..2797b2e589 --- /dev/null +++ b/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs @@ -0,0 +1,176 @@ +using Bit.Billing.Constants; +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Services; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Billing.Test.Services; + +public class SubscriptionDeletedHandlerTests +{ + private readonly IStripeEventService _stripeEventService; + private readonly IUserService _userService; + private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly SubscriptionDeletedHandler _sut; + + public SubscriptionDeletedHandlerTests() + { + _stripeEventService = Substitute.For(); + _userService = Substitute.For(); + _stripeEventUtilityService = Substitute.For(); + _organizationDisableCommand = Substitute.For(); + _sut = new SubscriptionDeletedHandler( + _stripeEventService, + _userService, + _stripeEventUtilityService, + _organizationDisableCommand); + } + + [Fact] + public async Task HandleAsync_SubscriptionNotCanceled_DoesNothing() + { + // Arrange + var stripeEvent = new Event(); + var subscription = new Subscription + { + Status = "active", + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary() + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, null, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default); + await _userService.DidNotReceiveWithAnyArgs().DisablePremiumAsync(default, default); + } + + [Fact] + public async Task HandleAsync_OrganizationSubscriptionCanceled_DisablesOrganization() + { + // Arrange + var stripeEvent = new Event(); + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary + { + { "organizationId", organizationId.ToString() } + } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _organizationDisableCommand.Received(1) + .DisableAsync(organizationId, subscription.CurrentPeriodEnd); + } + + [Fact] + public async Task HandleAsync_UserSubscriptionCanceled_DisablesUserPremium() + { + // Arrange + var stripeEvent = new Event(); + var userId = Guid.NewGuid(); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary + { + { "userId", userId.ToString() } + } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _userService.Received(1) + .DisablePremiumAsync(userId, subscription.CurrentPeriodEnd); + } + + [Fact] + public async Task HandleAsync_ProviderMigrationCancellation_DoesNotDisableOrganization() + { + // Arrange + var stripeEvent = new Event(); + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary + { + { "organizationId", organizationId.ToString() } + }, + CancellationDetails = new SubscriptionCancellationDetails + { + Comment = "Cancelled as part of provider migration to Consolidated Billing" + } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _organizationDisableCommand.DidNotReceiveWithAnyArgs() + .DisableAsync(default, default); + } + + [Fact] + public async Task HandleAsync_AddedToProviderCancellation_DoesNotDisableOrganization() + { + // Arrange + var stripeEvent = new Event(); + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Metadata = new Dictionary + { + { "organizationId", organizationId.ToString() } + }, + CancellationDetails = new SubscriptionCancellationDetails + { + Comment = "Organization was added to Provider" + } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + await _organizationDisableCommand.DidNotReceiveWithAnyArgs() + .DisableAsync(default, default); + } +} diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs new file mode 100644 index 0000000000..a6ac7e9512 --- /dev/null +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -0,0 +1,361 @@ +using Bit.Billing.Constants; +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Pricing; +using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Quartz; +using Stripe; +using Xunit; +using Event = Stripe.Event; + +namespace Bit.Billing.Test.Services; + +public class SubscriptionUpdatedHandlerTests +{ + private readonly IStripeEventService _stripeEventService; + private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IOrganizationService _organizationService; + private readonly IStripeFacade _stripeFacade; + private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; + private readonly IUserService _userService; + private readonly IPushNotificationService _pushNotificationService; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISchedulerFactory _schedulerFactory; + private readonly IFeatureService _featureService; + private readonly IOrganizationEnableCommand _organizationEnableCommand; + private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly IPricingClient _pricingClient; + private readonly IScheduler _scheduler; + private readonly SubscriptionUpdatedHandler _sut; + + public SubscriptionUpdatedHandlerTests() + { + _stripeEventService = Substitute.For(); + _stripeEventUtilityService = Substitute.For(); + _organizationService = Substitute.For(); + _stripeFacade = Substitute.For(); + _organizationSponsorshipRenewCommand = Substitute.For(); + _userService = Substitute.For(); + _pushNotificationService = Substitute.For(); + _organizationRepository = Substitute.For(); + _schedulerFactory = Substitute.For(); + _featureService = Substitute.For(); + _organizationEnableCommand = Substitute.For(); + _organizationDisableCommand = Substitute.For(); + _pricingClient = Substitute.For(); + _scheduler = Substitute.For(); + + _schedulerFactory.GetScheduler().Returns(_scheduler); + + _sut = new SubscriptionUpdatedHandler( + _stripeEventService, + _stripeEventUtilityService, + _organizationService, + _stripeFacade, + _organizationSponsorshipRenewCommand, + _userService, + _pushNotificationService, + _organizationRepository, + _schedulerFactory, + _featureService, + _organizationEnableCommand, + _organizationDisableCommand, + _pricingClient); + } + + [Fact] + public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizationAndSchedulesCancellation() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert) + .Returns(true); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationDisableCommand.Received(1) + .DisableAsync(organizationId, currentPeriodEnd); + await _scheduler.Received(1).ScheduleJob( + Arg.Is(j => j.Key.Name == $"cancel-sub-{subscriptionId}"), + Arg.Is(t => t.Key.Name == $"cancel-trigger-{subscriptionId}")); + } + + [Fact] + public async Task HandleAsync_UnpaidUserSubscription_DisablesPremiumAndCancelsSubscription() + { + // Arrange + var userId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "userId", userId.ToString() } }, + Items = new StripeList + { + Data = new List + { + new() { Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } } + } + } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + + _stripeFacade.ListInvoices(Arg.Any()) + .Returns(new StripeList { Data = new List() }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userService.Received(1) + .DisablePremiumAsync(userId, currentPeriodEnd); + await _stripeFacade.Received(1) + .CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.Received(1) + .ListInvoices(Arg.Is(o => + o.Status == StripeInvoiceStatus.Open && o.Subscription == subscriptionId)); + } + + [Fact] + public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganizationAndUpdatesExpiration() + { + // Arrange + var organizationId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Active, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } + }; + + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually2023 + }; + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + _stripeFacade.ListInvoices(Arg.Any()) + .Returns(new StripeList { Data = new List { new Invoice { Id = "inv_123" } } }); + + var plan = new Enterprise2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationEnableCommand.Received(1) + .EnableAsync(organizationId); + await _organizationService.Received(1) + .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); + await _pushNotificationService.Received(1) + .PushSyncOrganizationStatusAsync(organization); + } + + [Fact] + public async Task HandleAsync_ActiveUserSubscription_EnablesPremiumAndUpdatesExpiration() + { + // Arrange + var userId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Active, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "userId", userId.ToString() } } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userService.Received(1) + .EnablePremiumAsync(userId, currentPeriodEnd); + await _userService.Received(1) + .UpdatePremiumExpirationAsync(userId, currentPeriodEnd); + } + + [Fact] + public async Task HandleAsync_SponsoredSubscription_RenewsSponsorship() + { + // Arrange + var organizationId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Active, + CurrentPeriodEnd = currentPeriodEnd, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _stripeEventUtilityService.IsSponsoredSubscription(subscription) + .Returns(true); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationSponsorshipRenewCommand.Received(1) + .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); + } + + [Fact] + public async Task HandleAsync_WhenSubscriptionIsActive_AndOrganizationHasSecretsManagerTrial_AndRemovingSecretsManagerTrial_RemovesPasswordManagerCoupon() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Id = "sub_123", + Status = StripeSubscriptionStatus.Active, + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + CustomerId = "cus_123", + Items = new StripeList + { + Data = new List + { + new() { Plan = new Stripe.Plan { Id = "2023-enterprise-org-seat-annually" } } + } + }, + Customer = new Customer + { + Balance = 0, + Discount = new Discount + { + Coupon = new Coupon { Id = "sm-standalone" } + } + }, + Discount = new Discount + { + Coupon = new Coupon { Id = "sm-standalone" } + }, + Metadata = new Dictionary + { + { "organizationId", organizationId.ToString() } + } + }; + + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually2023 + }; + + var plan = new Enterprise2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(new + { + items = new + { + data = new[] + { + new { plan = new { id = "secrets-manager-enterprise-seat-annually" } } + } + }, + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Plan = new Stripe.Plan { Id = "secrets-manager-enterprise-seat-annually" } + } + } + } + }) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).DeleteCustomerDiscount(subscription.CustomerId); + await _stripeFacade.Received(1).DeleteSubscriptionDiscount(subscription.Id); + } +}