From f5157087950cd52751ff6f4b78564b1b8886165e Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Tue, 27 Jan 2026 15:24:31 -0600 Subject: [PATCH 1/3] Consolidate unpaid subscription handling --- .../SubscriptionUpdatedHandler.cs | 329 +++----- .../SubscriptionUpdatedHandlerTests.cs | 704 +++++++++--------- 2 files changed, 443 insertions(+), 590 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 9e20bd3191c6..4507d9e30855 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,17 +1,15 @@ -using Bit.Billing.Constants; -using Bit.Billing.Jobs; -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; -using Quartz; using Stripe; using Stripe.TestHelpers; +using static Bit.Core.Billing.Constants.StripeConstants; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -25,14 +23,11 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; private readonly IOrganizationRepository _organizationRepository; - private readonly ISchedulerFactory _schedulerFactory; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; - private readonly IFeatureService _featureService; private readonly IProviderRepository _providerRepository; private readonly IProviderService _providerService; - private readonly ILogger _logger; private readonly IPushNotificationAdapter _pushNotificationAdapter; public SubscriptionUpdatedHandler( @@ -43,14 +38,11 @@ public SubscriptionUpdatedHandler( IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, IUserService userService, IOrganizationRepository organizationRepository, - ISchedulerFactory schedulerFactory, IOrganizationEnableCommand organizationEnableCommand, IOrganizationDisableCommand organizationDisableCommand, IPricingClient pricingClient, - IFeatureService featureService, IProviderRepository providerRepository, IProviderService providerService, - ILogger logger, IPushNotificationAdapter pushNotificationAdapter) { _stripeEventService = stripeEventService; @@ -62,183 +54,147 @@ public SubscriptionUpdatedHandler( _userService = userService; _organizationRepository = organizationRepository; _providerRepository = providerRepository; - _schedulerFactory = schedulerFactory; _organizationEnableCommand = organizationEnableCommand; _organizationDisableCommand = organizationDisableCommand; _pricingClient = pricingClient; - _featureService = featureService; _providerRepository = providerRepository; _providerService = providerService; - _logger = logger; _pushNotificationAdapter = pushNotificationAdapter; } - /// - /// Handles the event type from Stripe. - /// - /// public async Task HandleAsync(Event parsedEvent) { var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]); - var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); + SubscriberId subscriberId = subscription; var currentPeriodEnd = subscription.GetCurrentPeriodEnd(); - switch (subscription.Status) + if (SubscriptionWentUnpaid(parsedEvent, subscription)) { - case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired - when organizationId.HasValue: - { - await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd); - if (subscription.Status == StripeSubscriptionStatus.Unpaid && - subscription.LatestInvoice is { BillingReason: "subscription_cycle" or "subscription_create" }) - { - await ScheduleCancellationJobAsync(subscription.Id, organizationId.Value); - } - break; - } - case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired when providerId.HasValue: + await DisableSubscriberAsync(subscriberId, currentPeriodEnd); + await SetSubscriptionToCancelAsync(subscription); + } + else if (SubscriptionBecameActive(parsedEvent, subscription)) + { + await EnableSubscriberAsync(subscriberId, currentPeriodEnd); + await RemovePendingCancellationAsync(subscription); + } + + await subscriberId.Match( + userId => _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd), + async organizationId => + { + await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd); + + if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue) { - await HandleUnpaidProviderSubscriptionAsync(providerId.Value, parsedEvent, subscription); - break; + await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value); } - case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired: - { - if (!userId.HasValue) - { - break; - } - if (await IsPremiumSubscriptionAsync(subscription)) - { - await CancelSubscription(subscription.Id); - await VoidOpenInvoices(subscription.Id); - } + await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription); + }, + _ => Task.CompletedTask); + } - await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); + private static bool SubscriptionWentUnpaid( + Event parsedEvent, + Subscription currentSubscription) => + parsedEvent.Data.PreviousAttributes.ToObject() is Subscription + { + Status: + SubscriptionStatus.Trialing or + SubscriptionStatus.Active or + SubscriptionStatus.PastDue + } && currentSubscription is + { + Status: SubscriptionStatus.Unpaid, + LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle + }; - break; - } - case StripeSubscriptionStatus.Incomplete when userId.HasValue: + private static bool SubscriptionBecameActive( + Event parsedEvent, + Subscription currentSubscription) => + parsedEvent.Data.PreviousAttributes.ToObject() is Subscription + { + Status: + SubscriptionStatus.Incomplete or + SubscriptionStatus.Unpaid + } && currentSubscription is + { + Status: SubscriptionStatus.Active, + LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle + }; + + private Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => + subscriberId.Match( + userId => _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd), + async organizationId => + { + await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd); + var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + if (organization != null) { - // Handle Incomplete subscriptions for Premium users that have open invoices from failed payments - // This prevents duplicate subscriptions when users retry the subscription flow - if (await IsPremiumSubscriptionAsync(subscription) && - subscription.LatestInvoice is { Status: StripeInvoiceStatus.Open }) - { - await CancelSubscription(subscription.Id); - await VoidOpenInvoices(subscription.Id); - await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); - } - - break; + await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); } - case StripeSubscriptionStatus.Active when organizationId.HasValue: + }, + async providerId => + { + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + if (provider != null) { - await _organizationEnableCommand.EnableAsync(organizationId.Value); - var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); - if (organization != null) - { - await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); - } - break; + provider.Enabled = false; + await _providerService.UpdateAsync(provider); } - case StripeSubscriptionStatus.Active when providerId.HasValue: + }); + + private Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => + subscriberId.Match( + userId => _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd), + async organizationId => + { + await _organizationEnableCommand.EnableAsync(organizationId.Value, currentPeriodEnd); + var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + if (organization != null) { - var provider = await _providerRepository.GetByIdAsync(providerId.Value); - if (provider != null) - { - provider.Enabled = true; - await _providerService.UpdateAsync(provider); - - if (IsProviderSubscriptionNowActive(parsedEvent, subscription)) - { - // Update the CancelAtPeriodEnd subscription option to prevent the now active provider subscription from being cancelled - var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }; - await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionUpdateOptions); - } - } - break; + await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); } - case StripeSubscriptionStatus.Active: + }, + async providerId => + { + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + if (provider != null) { - if (userId.HasValue) - { - await _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd); - } - break; + provider.Enabled = true; + await _providerService.UpdateAsync(provider); } - } - - if (organizationId.HasValue) - { - await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd); - if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue) - { - await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value); - } - - await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription); - } - else if (userId.HasValue) - { - await _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd); - } - } - - private async Task CancelSubscription(string subscriptionId) => - await _stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions()); + }); - private async Task VoidOpenInvoices(string subscriptionId) + private async Task SetSubscriptionToCancelAsync(Subscription subscription) { - var options = new InvoiceListOptions - { - Status = StripeInvoiceStatus.Open, - Subscription = subscriptionId - }; - var invoices = await _stripeFacade.ListInvoices(options); - foreach (var invoice in invoices) + if (subscription.TestClock != null) { - await _stripeFacade.VoidInvoice(invoice.Id); + await WaitForTestClockToAdvanceAsync(subscription.TestClock); } - } - private async Task IsPremiumSubscriptionAsync(Subscription subscription) - { - var premiumPlans = await _pricingClient.ListPremiumPlans(); - var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet(); - return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id)); - } + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; - /// - /// Checks if the provider subscription status has changed from a non-active to an active status type - /// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false. - /// - /// The event containing the previous subscription status - /// The current subscription status - /// A boolean that represents whether the event status has changed from a non-active status to an active status - private static bool IsProviderSubscriptionNowActive(Event parsedEvent, Subscription subscription) - { - if (parsedEvent.Data.PreviousAttributes == null) + await _stripeFacade.UpdateSubscription(subscription.Id, new SubscriptionUpdateOptions { - return false; - } - - var previousSubscription = parsedEvent - .Data - .PreviousAttributes - .ToObject() as Subscription; + CancelAt = now.AddDays(7), + ProrationBehavior = ProrationBehavior.None, + CancellationDetails = new SubscriptionCancellationDetailsOptions + { + Comment = $"Automation: Setting unpaid subscription to cancel 7 days from {now:yyyy-MM-dd}." + } + }); + } - return previousSubscription?.Status switch + private async Task RemovePendingCancellationAsync(Subscription subscription) + => await _stripeFacade.UpdateSubscription(subscription.Id, new SubscriptionUpdateOptions { - StripeSubscriptionStatus.IncompleteExpired - or StripeSubscriptionStatus.Paused - or StripeSubscriptionStatus.Incomplete - or StripeSubscriptionStatus.Unpaid - when subscription.Status == StripeSubscriptionStatus.Active => true, - _ => false - }; - } + CancelAtPeriodEnd = false, + ProrationBehavior = ProrationBehavior.None + }); /// /// Removes the Password Manager coupon if the organization is removing the Secrets Manager trial. @@ -305,7 +261,7 @@ private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync ?.Id == "sm-standalone"; var subscriptionHasSecretsManagerTrial = subscription.Discounts.Select(discount => discount.Coupon.Id) - .Contains(StripeConstants.CouponIDs.SecretsManagerStandalone); + .Contains(CouponIDs.SecretsManagerStandalone); if (customerHasSecretsManagerTrial) { @@ -318,75 +274,6 @@ private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync } } - private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId) - { - var scheduler = await _schedulerFactory.GetScheduler(); - - var job = JobBuilder.Create() - .WithIdentity($"cancel-sub-{subscriptionId}", "subscription-cancellations") - .UsingJobData("subscriptionId", subscriptionId) - .UsingJobData("organizationId", organizationId.ToString()) - .Build(); - - var trigger = TriggerBuilder.Create() - .WithIdentity($"cancel-trigger-{subscriptionId}", "subscription-cancellations") - .StartAt(DateTimeOffset.UtcNow.AddDays(7)) - .Build(); - - await scheduler.ScheduleJob(job, trigger); - } - - private async Task HandleUnpaidProviderSubscriptionAsync( - Guid providerId, - Event parsedEvent, - Subscription currentSubscription) - { - var provider = await _providerRepository.GetByIdAsync(providerId); - if (provider == null) - { - return; - } - - try - { - provider.Enabled = false; - await _providerService.UpdateAsync(provider); - - if (parsedEvent.Data.PreviousAttributes != null) - { - var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject() as Subscription; - - if (previousSubscription is - { - Status: - StripeSubscriptionStatus.Trialing or - StripeSubscriptionStatus.Active or - StripeSubscriptionStatus.PastDue - } && currentSubscription is - { - Status: StripeSubscriptionStatus.Unpaid, - LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" - }) - { - if (currentSubscription.TestClock != null) - { - await WaitForTestClockToAdvanceAsync(currentSubscription.TestClock); - } - - var now = currentSubscription.TestClock?.FrozenTime ?? DateTime.UtcNow; - - var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) }; - - await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions); - } - } - } - catch (Exception exception) - { - _logger.LogError(exception, "An error occurred while trying to disable and schedule subscription cancellation for provider ({ProviderID})", providerId); - } - } - private async Task WaitForTestClockToAdvanceAsync(TestClock testClock) { while (testClock.Status != "ready") diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 2259d846b7fb..6d74146b03dc 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -1,5 +1,4 @@ -using Bit.Billing.Constants; -using Bit.Billing.Services; +using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -13,16 +12,13 @@ using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; using Bit.Core.Test.Billing.Mocks.Plans; -using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using NSubstitute; using NSubstitute.ReturnsExtensions; -using Quartz; using Stripe; using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; using Event = Stripe.Event; -using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; -using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; namespace Bit.Billing.Test.Services; @@ -38,10 +34,8 @@ public class SubscriptionUpdatedHandlerTests private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; - private readonly IFeatureService _featureService; private readonly IProviderRepository _providerRepository; private readonly IProviderService _providerService; - private readonly IScheduler _scheduler; private readonly IPushNotificationAdapter _pushNotificationAdapter; private readonly SubscriptionUpdatedHandler _sut; @@ -55,19 +49,13 @@ public SubscriptionUpdatedHandlerTests() _userService = Substitute.For(); _providerService = Substitute.For(); _organizationRepository = Substitute.For(); - var schedulerFactory = Substitute.For(); _organizationEnableCommand = Substitute.For(); _organizationDisableCommand = Substitute.For(); _pricingClient = Substitute.For(); - _featureService = Substitute.For(); _providerRepository = Substitute.For(); _providerService = Substitute.For(); - var logger = Substitute.For>(); - _scheduler = Substitute.For(); _pushNotificationAdapter = Substitute.For(); - schedulerFactory.GetScheduler().Returns(_scheduler); - _sut = new SubscriptionUpdatedHandler( _stripeEventService, _stripeEventUtilityService, @@ -76,46 +64,66 @@ public SubscriptionUpdatedHandlerTests() _organizationSponsorshipRenewCommand, _userService, _organizationRepository, - schedulerFactory, _organizationEnableCommand, _organizationDisableCommand, _pricingClient, - _featureService, _providerRepository, _providerService, - logger, _pushNotificationAdapter); } [Fact] - public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizationAndSchedulesCancellation() + public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizationAndSetsCancellation() { // Arrange var organizationId = Guid.NewGuid(); var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Active + }; + var subscription = new Subscription { Id = subscriptionId, - Status = StripeSubscriptionStatus.Unpaid, + Status = SubscriptionStatus.Unpaid, Items = new StripeList { Data = [ - new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + } ] }, Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, - LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; - var parsedEvent = new Event { Data = new EventData() }; + var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; _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); + + var plan = new Enterprise2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + _pricingClient.ListPlans().Returns(MockPlans.Plans); // Act await _sut.HandleAsync(parsedEvent); @@ -123,14 +131,21 @@ public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizatio // 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}")); + await _pushNotificationAdapter.Received(1) + .NotifyEnabledChangedAsync(organization); + await _stripeFacade.Received(1).UpdateSubscription( + subscriptionId, + Arg.Is(options => + options.CancelAt.HasValue && + options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) && + options.ProrationBehavior == ProrationBehavior.None && + options.CancellationDetails != null && + options.CancellationDetails.Comment != null)); } [Fact] public async Task - HandleAsync_UnpaidProviderSubscription_WithValidTransition_DisablesProviderAndSchedulesCancellation() + HandleAsync_UnpaidProviderSubscription_WithValidTransition_DisablesProviderAndSetsCancellation() { // Arrange var providerId = Guid.NewGuid(); @@ -139,14 +154,13 @@ public async Task var previousSubscription = new Subscription { Id = subscriptionId, - Status = StripeSubscriptionStatus.Active, - Metadata = new Dictionary { ["providerId"] = providerId.ToString() } + Status = SubscriptionStatus.Active }; var currentSubscription = new Subscription { Id = subscriptionId, - Status = StripeSubscriptionStatus.Unpaid, + Status = SubscriptionStatus.Unpaid, Items = new StripeList { Data = @@ -155,14 +169,12 @@ public async Task ] }, Metadata = new Dictionary { ["providerId"] = providerId.ToString() }, - LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }, TestClock = null }; var parsedEvent = new Event { - Id = "evt_test123", - Type = HandledStripeWebhook.SubscriptionUpdated, Data = new EventData { Object = currentSubscription, @@ -173,8 +185,6 @@ public async Task var provider = new Provider { Id = providerId, Enabled = true }; _stripeEventService.GetSubscription(parsedEvent, true, Arg.Any>()).Returns(currentSubscription); - _stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata) - .Returns(Tuple.Create(null, null, providerId)); _providerRepository.GetByIdAsync(providerId).Returns(provider); // Act @@ -189,16 +199,25 @@ await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => options.CancelAt.HasValue && - options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1))); + options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) && + options.ProrationBehavior == ProrationBehavior.None && + options.CancellationDetails != null && + options.CancellationDetails.Comment != null)); } [Fact] - public async Task HandleAsync_UnpaidProviderSubscription_WithoutValidTransition_DisablesProviderOnly() + public async Task HandleAsync_UnpaidProviderSubscription_WithoutValidTransition_DoesNotDisableProvider() { // Arrange var providerId = Guid.NewGuid(); const string subscriptionId = "sub_123"; + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Unpaid // No valid transition (already unpaid) + }; + var subscription = new Subscription { Id = subscriptionId, @@ -209,9 +228,9 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithoutValidTransition_ new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } ] }, - Status = StripeSubscriptionStatus.Unpaid, + Status = SubscriptionStatus.Unpaid, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, - LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; @@ -220,38 +239,40 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithoutValidTransition_ { Data = new EventData { - PreviousAttributes = JObject.FromObject(new - { - status = "unpaid" // No valid transition - }) + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) } }; _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); - _providerRepository.GetByIdAsync(providerId) .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); - // Assert - Assert.False(provider.Enabled); - await _providerService.Received(1).UpdateAsync(provider); + // Assert - No disable or cancellation since there was no valid status transition + Assert.True(provider.Enabled); + await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } [Fact] - public async Task HandleAsync_UnpaidProviderSubscription_WithNoPreviousAttributes_DisablesProviderOnly() + public async Task HandleAsync_UnpaidProviderSubscription_WithNonMatchingPreviousStatus_DoesNotDisableProvider() { // Arrange var providerId = Guid.NewGuid(); const string subscriptionId = "sub_123"; + // Previous status is Canceled, which is not a valid transition source (Trialing/Active/PastDue) + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Canceled + }; + var subscription = new Subscription { Id = subscriptionId, @@ -262,45 +283,56 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithNoPreviousAttribute new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } ] }, - Status = StripeSubscriptionStatus.Unpaid, + Status = SubscriptionStatus.Unpaid, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, - LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; - var parsedEvent = new Event { Data = new EventData { PreviousAttributes = null } }; + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); - _providerRepository.GetByIdAsync(providerId) .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); - // Assert - Assert.False(provider.Enabled); - await _providerService.Received(1).UpdateAsync(provider); + // Assert - No disable or cancellation since the previous status (Canceled) is not a valid transition source + Assert.True(provider.Enabled); + await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } [Fact] - public async Task HandleAsync_UnpaidProviderSubscription_WithIncompleteExpiredStatus_DisablesProvider() + public async Task HandleAsync_ProviderSubscription_WithIncompleteExpiredStatus_DoesNotDisableProvider() { // Arrange var providerId = Guid.NewGuid(); var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + // Previous status that doesn't trigger enable/disable logic + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Incomplete + }; + var subscription = new Subscription { Id = subscriptionId, - Status = StripeSubscriptionStatus.IncompleteExpired, + Status = SubscriptionStatus.IncompleteExpired, Items = new StripeList { Data = @@ -314,38 +346,48 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithIncompleteExpiredSt var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; - var parsedEvent = new Event { Data = new EventData() }; + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); - _providerRepository.GetByIdAsync(providerId) .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); - // Assert - Assert.False(provider.Enabled); - await _providerService.Received(1).UpdateAsync(provider); + // Assert - IncompleteExpired status is not handled by the new logic + Assert.True(provider.Enabled); + await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } [Fact] - public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_DoesNothing() + public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_StillSetsCancellation() { // Arrange var providerId = Guid.NewGuid(); var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Active + }; + var subscription = new Subscription { Id = subscriptionId, - Status = StripeSubscriptionStatus.Unpaid, + Status = SubscriptionStatus.Unpaid, Items = new StripeList { Data = @@ -354,144 +396,144 @@ public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_Do ] }, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, - LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; - var parsedEvent = new Event { Data = new EventData() }; + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); - _providerRepository.GetByIdAsync(providerId) .Returns((Provider)null); // Act await _sut.HandleAsync(parsedEvent); - // Assert + // Assert - Provider not updated (since not found), but cancellation is still set await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); - await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription( + subscriptionId, + Arg.Is(options => + options.CancelAt.HasValue && + options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) && + options.ProrationBehavior == ProrationBehavior.None && + options.CancellationDetails != null && + options.CancellationDetails.Comment != null)); } [Fact] - public async Task HandleAsync_UnpaidUserSubscription_DisablesPremiumAndCancelsSubscription() + public async Task HandleAsync_UnpaidUserSubscription_DisablesPremiumAndSetsCancellation() { // Arrange var userId = Guid.NewGuid(); var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Active + }; + var subscription = new Subscription { Id = subscriptionId, - Status = StripeSubscriptionStatus.Unpaid, + Status = SubscriptionStatus.Unpaid, Metadata = new Dictionary { { "userId", userId.ToString() } }, Items = new StripeList { Data = [ - new SubscriptionItem - { - CurrentPeriodEnd = currentPeriodEnd, - Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } - } + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } ] - } + }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; - var parsedEvent = new Event { Data = new EventData() }; - - var premiumPlan = new PremiumPlan + var parsedEvent = new Event { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId }, - Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" } + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } }; - _pricingClient.ListPremiumPlans().Returns(new List { premiumPlan }); _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)); + await _stripeFacade.Received(1).UpdateSubscription( + subscriptionId, + Arg.Is(options => + options.CancelAt.HasValue && + options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) && + options.ProrationBehavior == ProrationBehavior.None && + options.CancellationDetails != null && + options.CancellationDetails.Comment != null)); } [Fact] - public async Task HandleAsync_IncompleteExpiredUserSubscription_DisablesPremiumAndCancelsSubscription() + public async Task HandleAsync_IncompleteExpiredUserSubscription_OnlyUpdatesExpiration() { // Arrange var userId = Guid.NewGuid(); var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + // Previous status that doesn't trigger enable/disable logic + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Incomplete + }; + var subscription = new Subscription { Id = subscriptionId, - Status = StripeSubscriptionStatus.IncompleteExpired, + Status = SubscriptionStatus.IncompleteExpired, Metadata = new Dictionary { { "userId", userId.ToString() } }, Items = new StripeList { Data = [ - new SubscriptionItem - { - CurrentPeriodEnd = currentPeriodEnd, - Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } - } + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } ] } }; - var parsedEvent = new Event { Data = new EventData() }; - - var premiumPlan = new PremiumPlan + var parsedEvent = new Event { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId }, - Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" } + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } }; - _pricingClient.ListPremiumPlans().Returns(new List { premiumPlan }); _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)); + // Assert - IncompleteExpired is no longer handled specially, only expiration is updated + await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any(), Arg.Any()); + await _userService.Received(1).UpdatePremiumExpirationAsync(userId, currentPeriodEnd); + await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } [Fact] @@ -499,49 +541,71 @@ public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganization { // Arrange var organizationId = Guid.NewGuid(); + var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Unpaid + }; + var subscription = new Subscription { - Status = StripeSubscriptionStatus.Active, + Id = subscriptionId, + Status = SubscriptionStatus.Active, Items = new StripeList { Data = [ - new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + } ] }, - Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 }; - var parsedEvent = new Event { Data = new EventData() }; + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; _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 Invoice { Id = "inv_123" }] }); - var plan = new Enterprise2023Plan(true); _pricingClient.GetPlanOrThrow(organization.PlanType) .Returns(plan); + _pricingClient.ListPlans() + .Returns(MockPlans.Plans); // Act await _sut.HandleAsync(parsedEvent); // Assert await _organizationEnableCommand.Received(1) - .EnableAsync(organizationId); + .EnableAsync(organizationId, currentPeriodEnd); await _organizationService.Received(1) .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); await _pushNotificationAdapter.Received(1) .NotifyEnabledChangedAsync(organization); + await _stripeFacade.Received(1).UpdateSubscription( + subscriptionId, + Arg.Is(options => + options.CancelAtPeriodEnd == false && + options.ProrationBehavior == ProrationBehavior.None)); } [Fact] @@ -549,10 +613,19 @@ public async Task HandleAsync_ActiveUserSubscription_EnablesPremiumAndUpdatesExp { // Arrange var userId = Guid.NewGuid(); + var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Unpaid + }; + var subscription = new Subscription { - Status = StripeSubscriptionStatus.Active, + Id = subscriptionId, + Status = SubscriptionStatus.Active, Items = new StripeList { Data = @@ -560,17 +633,22 @@ public async Task HandleAsync_ActiveUserSubscription_EnablesPremiumAndUpdatesExp new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } ] }, - Metadata = new Dictionary { { "userId", userId.ToString() } } + Metadata = new Dictionary { { "userId", userId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; - var parsedEvent = new Event { Data = new EventData() }; + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; _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); @@ -579,6 +657,11 @@ await _userService.Received(1) .EnablePremiumAsync(userId, currentPeriodEnd); await _userService.Received(1) .UpdatePremiumExpirationAsync(userId, currentPeriodEnd); + await _stripeFacade.Received(1).UpdateSubscription( + subscriptionId, + Arg.Is(options => + options.CancelAtPeriodEnd == false && + options.ProrationBehavior == ProrationBehavior.None)); } [Fact] @@ -586,10 +669,20 @@ public async Task HandleAsync_SponsoredSubscription_RenewsSponsorship() { // Arrange var organizationId = Guid.NewGuid(); + var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + // Use a previous status that won't trigger enable/disable logic + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Active + }; + var subscription = new Subscription { - Status = StripeSubscriptionStatus.Active, + Id = subscriptionId, + Status = SubscriptionStatus.Active, Items = new StripeList { Data = @@ -600,14 +693,18 @@ public async Task HandleAsync_SponsoredSubscription_RenewsSponsorship() Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } }; - var parsedEvent = new Event { Data = new EventData() }; + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; _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); @@ -628,7 +725,7 @@ public async Task var subscription = new Subscription { Id = "sub_123", - Status = StripeSubscriptionStatus.Active, + Status = SubscriptionStatus.Active, CustomerId = "cus_123", Items = new StripeList { @@ -637,7 +734,7 @@ public async Task new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), - Plan = new Stripe.Plan { Id = "2023-enterprise-org-seat-annually" } + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } } ] }, @@ -673,7 +770,7 @@ public async Task { Data = [ - new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-enterprise-seat-annually" } } + new SubscriptionItem { Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } } ] } }) @@ -683,9 +780,6 @@ public async Task _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); @@ -705,22 +799,22 @@ public async Task var subscription = new Subscription { Id = "sub_123", - Status = StripeSubscriptionStatus.Active, + Status = SubscriptionStatus.Active, CustomerId = "cus_123", Items = new StripeList { Data = [ new SubscriptionItem - { - CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), - Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } - }, - new SubscriptionItem - { - CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), - Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } - } + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + }, + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } + } ] }, Customer = new Customer @@ -752,15 +846,15 @@ public async Task { data = new[] { - new { plan = new { id = "secrets-manager-teams-seat-annually" } }, - } + new { plan = new { id = "secrets-manager-teams-seat-annually" } }, + } }, Items = new StripeList { Data = [ - new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } }, - ] + new SubscriptionItem { Plan = new Plan { Id = "secrets-manager-teams-seat-annually" } }, + ] } }) } @@ -769,9 +863,6 @@ public async Task _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); @@ -784,9 +875,9 @@ public async Task } [Theory] - [MemberData(nameof(GetNonActiveSubscriptions))] + [MemberData(nameof(GetValidTransitionToActiveSubscriptions))] public async Task - HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasNonActive_EnableProviderAndUpdateSubscription( + HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasIncompleteOrUnpaid_EnableProviderAndUpdateSubscription( Subscription previousSubscription) { // Arrange @@ -797,10 +888,6 @@ public async Task .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _stripeEventUtilityService - .GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); - _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); @@ -815,9 +902,6 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - _stripeEventUtilityService - .Received(1) - .GetIdsFromMetadata(newSubscription.Metadata); await _providerRepository .Received(1) .GetByIdAsync(providerId); @@ -827,24 +911,23 @@ await _providerService await _stripeFacade .Received(1) .UpdateSubscription(newSubscription.Id, - Arg.Is(options => options.CancelAtPeriodEnd == false)); + Arg.Is(options => + options.CancelAtPeriodEnd == false && + options.ProrationBehavior == ProrationBehavior.None)); } [Fact] public async Task - HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasCanceled_EnableProvider() + HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasCanceled_DoesNotEnableProvider() { // Arrange - var previousSubscription = new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Canceled }; + var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Canceled }; var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _stripeEventUtilityService - .GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); @@ -852,17 +935,14 @@ public async Task // Act await _sut.HandleAsync(parsedEvent); - // Assert + // Assert - Canceled is not a valid transition source for SubscriptionBecameActive await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - _stripeEventUtilityService - .Received(1) - .GetIdsFromMetadata(newSubscription.Metadata); - await _providerRepository.Received(1).GetByIdAsync(providerId); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); await _providerService - .Received(1) - .UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == true)); + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -870,19 +950,16 @@ await _stripeFacade [Fact] public async Task - HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasAlreadyActive_EnableProvider() + HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasAlreadyActive_DoesNotEnableProvider() { // Arrange - var previousSubscription = new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Active }; + var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Active }; var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _stripeEventUtilityService - .GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); @@ -890,17 +967,14 @@ public async Task // Act await _sut.HandleAsync(parsedEvent); - // Assert + // Assert - Already Active is not a valid transition for SubscriptionBecameActive await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - _stripeEventUtilityService - .Received(1) - .GetIdsFromMetadata(newSubscription.Metadata); - await _providerRepository.Received(1).GetByIdAsync(providerId); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); await _providerService - .Received(1) - .UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == true)); + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -908,19 +982,16 @@ await _stripeFacade [Fact] public async Task - HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasTrailing_EnableProvider() + HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasTrialing_DoesNotEnableProvider() { // Arrange - var previousSubscription = new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Trialing }; + var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Trialing }; var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _stripeEventUtilityService - .GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); @@ -928,17 +999,14 @@ public async Task // Act await _sut.HandleAsync(parsedEvent); - // Assert + // Assert - Trialing is not a valid transition source for SubscriptionBecameActive await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - _stripeEventUtilityService - .Received(1) - .GetIdsFromMetadata(newSubscription.Metadata); - await _providerRepository.Received(1).GetByIdAsync(providerId); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); await _providerService - .Received(1) - .UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == true)); + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -946,20 +1014,16 @@ await _stripeFacade [Fact] public async Task - HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasPastDue_EnableProvider() + HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasPastDue_DoesNotEnableProvider() { // Arrange - var previousSubscription = new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.PastDue }; + var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.PastDue }; var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); - _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _stripeEventUtilityService - .GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); @@ -967,19 +1031,14 @@ public async Task // Act await _sut.HandleAsync(parsedEvent); - // Assert + // Assert - PastDue is not a valid transition source for SubscriptionBecameActive await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - _stripeEventUtilityService - .Received(1) - .GetIdsFromMetadata(newSubscription.Metadata); - await _providerRepository - .Received(1) - .GetByIdAsync(Arg.Any()); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); await _providerService - .Received(1) - .UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == true)); + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -989,16 +1048,13 @@ await _stripeFacade public async Task HandleAsync_ActiveProviderSubscriptionEvent_AndProviderDoesNotExist_NoChanges() { // Arrange - var previousSubscription = new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Unpaid }; + var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Unpaid }; var (providerId, newSubscription, _, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _stripeEventUtilityService - .GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); _providerRepository .GetByIdAsync(Arg.Any()) .ReturnsNull(); @@ -1010,9 +1066,6 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_AndProviderDoesNot await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - _stripeEventUtilityService - .Received(1) - .GetIdsFromMetadata(newSubscription.Metadata); await _providerRepository .Received(1) .GetByIdAsync(providerId); @@ -1025,18 +1078,16 @@ await _stripeFacade } [Fact] - public async Task HandleAsync_ActiveProviderSubscriptionEvent_WithNoPreviousAttributes_EnableProvider() + public async Task HandleAsync_ActiveProviderSubscriptionEvent_WithNonMatchingPreviousStatus_DoesNotEnableProvider() { - // Arrange + // Arrange - Using a previous status (Canceled) that doesn't trigger SubscriptionBecameActive + var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Canceled }; var (providerId, newSubscription, provider, parsedEvent) = - CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(null); + CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _stripeEventUtilityService - .GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); @@ -1044,19 +1095,14 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_WithNoPreviousAttr // Act await _sut.HandleAsync(parsedEvent); - // Assert + // Assert - Canceled is not a valid transition source, so no enable logic is triggered await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - _stripeEventUtilityService - .Received(1) - .GetIdsFromMetadata(newSubscription.Metadata); - await _providerRepository - .Received(1) - .GetByIdAsync(Arg.Any()); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); await _providerService - .Received(1) - .UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == true)); + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceive() .UpdateSubscription(Arg.Any()); @@ -1076,8 +1122,9 @@ private static (Guid providerId, Subscription newSubscription, Provider provider new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } ] }, - Status = StripeSubscriptionStatus.Active, - Metadata = new Dictionary { { "providerId", providerId.ToString() } } + Status = SubscriptionStatus.Active, + Metadata = new Dictionary { { "providerId", providerId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; var provider = new Provider { Id = providerId, Enabled = false }; @@ -1094,144 +1141,63 @@ private static (Guid providerId, Subscription newSubscription, Provider provider } [Fact] - public async Task HandleAsync_IncompleteUserSubscriptionWithOpenInvoice_CancelsSubscriptionAndDisablesPremium() + public async Task HandleAsync_IncompleteUserSubscription_OnlyUpdatesExpiration() { // Arrange var userId = Guid.NewGuid(); var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - var openInvoice = new Invoice - { - Id = "inv_123", - Status = StripeInvoiceStatus.Open - }; - var subscription = new Subscription - { - Id = subscriptionId, - Status = StripeSubscriptionStatus.Incomplete, - Metadata = new Dictionary { { "userId", userId.ToString() } }, - LatestInvoice = openInvoice, - Items = new StripeList - { - Data = - [ - new SubscriptionItem - { - CurrentPeriodEnd = currentPeriodEnd, - Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } - } - ] - } - }; - var parsedEvent = new Event { Data = new EventData() }; - - var premiumPlan = new PremiumPlan + // Previous status that doesn't trigger enable/disable logic (already was incomplete) + var previousSubscription = new Subscription { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId }, - Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" } + Id = subscriptionId, + Status = SubscriptionStatus.Incomplete }; - _pricingClient.ListPremiumPlans().Returns(new List { premiumPlan }); - - _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 { openInvoice } }); - - // 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)); - await _stripeFacade.Received(1) - .VoidInvoice(openInvoice.Id); - } - - [Fact] - public async Task HandleAsync_IncompleteUserSubscriptionWithoutOpenInvoice_DoesNotCancelSubscription() - { - // Arrange - var userId = Guid.NewGuid(); - var subscriptionId = "sub_123"; - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - var paidInvoice = new Invoice - { - Id = "inv_123", - Status = StripeInvoiceStatus.Paid - }; var subscription = new Subscription { Id = subscriptionId, - Status = StripeSubscriptionStatus.Incomplete, + Status = SubscriptionStatus.Incomplete, Metadata = new Dictionary { { "userId", userId.ToString() } }, - LatestInvoice = paidInvoice, + LatestInvoice = new Invoice { Status = "open" }, Items = new StripeList { Data = [ - new SubscriptionItem - { - CurrentPeriodEnd = currentPeriodEnd, - Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } - } + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } ] } }; - var parsedEvent = new Event { Data = new EventData() }; - - var premiumPlan = new PremiumPlan + var parsedEvent = new Event { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId }, - Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" } + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } }; - _pricingClient.ListPremiumPlans().Returns(new List { premiumPlan }); _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.DidNotReceive() - .DisablePremiumAsync(Arg.Any(), Arg.Any()); - await _stripeFacade.DidNotReceive() - .CancelSubscription(Arg.Any(), Arg.Any()); - await _stripeFacade.DidNotReceive() - .ListInvoices(Arg.Any()); + // Assert - Incomplete status is no longer handled specially, only expiration is updated + await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any(), Arg.Any()); + await _userService.Received(1).UpdatePremiumExpirationAsync(userId, currentPeriodEnd); + await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } - public static IEnumerable GetNonActiveSubscriptions() + public static IEnumerable GetValidTransitionToActiveSubscriptions() { + // Only Incomplete and Unpaid are valid previous statuses for SubscriptionBecameActive return new List { - new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Unpaid } }, - new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Incomplete } }, - new object[] - { - new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.IncompleteExpired } - }, - new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Paused } } + new object[] { new Subscription { Id = "sub_123", Status = SubscriptionStatus.Unpaid } }, + new object[] { new Subscription { Id = "sub_123", Status = SubscriptionStatus.Incomplete } } }; } } From fe0bc1516b86a44cfadc7c2cd31bbb269051046e Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Tue, 3 Feb 2026 12:54:46 -0600 Subject: [PATCH 2/3] Move enable/disable operations to SubscriberService --- .../PaymentSucceededHandler.cs | 1 + .../SetupIntentSucceededHandler.cs | 1 + .../SubscriptionUpdatedHandler.cs | 80 +----- src/Billing/Startup.cs | 1 - .../Extensions/ServiceCollectionExtensions.cs | 2 + .../IPushNotificationAdapter.cs | 2 +- .../Notifications}/PushNotificationAdapter.cs | 2 +- .../Billing/Services/ISubscriberService.cs | 17 ++ .../Implementations/SubscriberService.cs | 56 +++- .../SetupIntentSucceededHandlerTests.cs | 1 + .../SubscriptionUpdatedHandlerTests.cs | 200 +++++---------- .../Services/SubscriberServiceTests.cs | 241 ++++++++++++++++++ 12 files changed, 394 insertions(+), 210 deletions(-) rename src/{Billing/Services => Core/Billing/Notifications}/IPushNotificationAdapter.cs (88%) rename src/{Billing/Services/Implementations => Core/Billing/Notifications}/PushNotificationAdapter.cs (98%) diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 443227f7bfe3..7b2eb554dba9 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -4,6 +4,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Pricing; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs index 89e40f0e438e..2324951ad822 100644 --- a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Services; using Bit.Core.Repositories; using OneOf; diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 4507d9e30855..1f2a3aaddf54 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,8 +1,6 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; @@ -22,13 +20,9 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationEnableCommand _organizationEnableCommand; - private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; - private readonly IProviderRepository _providerRepository; - private readonly IProviderService _providerService; - private readonly IPushNotificationAdapter _pushNotificationAdapter; + private readonly ISubscriberService _subscriberService; + private readonly IOrganizationRepository _organizationRepository; public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, @@ -37,29 +31,19 @@ public SubscriptionUpdatedHandler( IStripeFacade stripeFacade, IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, IUserService userService, - IOrganizationRepository organizationRepository, - IOrganizationEnableCommand organizationEnableCommand, - IOrganizationDisableCommand organizationDisableCommand, IPricingClient pricingClient, - IProviderRepository providerRepository, - IProviderService providerService, - IPushNotificationAdapter pushNotificationAdapter) + ISubscriberService subscriberService, + IOrganizationRepository organizationRepository) { _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; _organizationService = organizationService; - _providerService = providerService; _stripeFacade = stripeFacade; _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand; _userService = userService; - _organizationRepository = organizationRepository; - _providerRepository = providerRepository; - _organizationEnableCommand = organizationEnableCommand; - _organizationDisableCommand = organizationDisableCommand; _pricingClient = pricingClient; - _providerRepository = providerRepository; - _providerService = providerService; - _pushNotificationAdapter = pushNotificationAdapter; + _subscriberService = subscriberService; + _organizationRepository = organizationRepository; } public async Task HandleAsync(Event parsedEvent) @@ -71,12 +55,12 @@ public async Task HandleAsync(Event parsedEvent) if (SubscriptionWentUnpaid(parsedEvent, subscription)) { - await DisableSubscriberAsync(subscriberId, currentPeriodEnd); + await _subscriberService.DisableSubscriberAsync(subscriberId, currentPeriodEnd); await SetSubscriptionToCancelAsync(subscription); } else if (SubscriptionBecameActive(parsedEvent, subscription)) { - await EnableSubscriberAsync(subscriberId, currentPeriodEnd); + await _subscriberService.EnableSubscriberAsync(subscriberId, currentPeriodEnd); await RemovePendingCancellationAsync(subscription); } @@ -125,50 +109,6 @@ SubscriptionStatus.Incomplete or LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle }; - private Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => - subscriberId.Match( - userId => _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd), - async organizationId => - { - await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd); - var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); - if (organization != null) - { - await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); - } - }, - async providerId => - { - var provider = await _providerRepository.GetByIdAsync(providerId.Value); - if (provider != null) - { - provider.Enabled = false; - await _providerService.UpdateAsync(provider); - } - }); - - private Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => - subscriberId.Match( - userId => _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd), - async organizationId => - { - await _organizationEnableCommand.EnableAsync(organizationId.Value, currentPeriodEnd); - var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); - if (organization != null) - { - await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); - } - }, - async providerId => - { - var provider = await _providerRepository.GetByIdAsync(providerId.Value); - if (provider != null) - { - provider.Enabled = true; - await _providerService.UpdateAsync(provider); - } - }); - private async Task SetSubscriptionToCancelAsync(Subscription subscription) { if (subscription.TestClock != null) diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index f5f98bfd53c7..6e2b93563d35 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -101,7 +101,6 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); // Add Quartz services first services.AddQuartz(q => diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index c61c4e6279a7..13a120a1f4ce 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Bit.Core.Billing.Caches.Implementations; using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Services; @@ -31,6 +32,7 @@ public static void AddBillingOperations(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddLicenseServices(); services.AddLicenseOperations(); services.AddPricingClient(); diff --git a/src/Billing/Services/IPushNotificationAdapter.cs b/src/Core/Billing/Notifications/IPushNotificationAdapter.cs similarity index 88% rename from src/Billing/Services/IPushNotificationAdapter.cs rename to src/Core/Billing/Notifications/IPushNotificationAdapter.cs index 2f74f35eecdc..7981c50428fa 100644 --- a/src/Billing/Services/IPushNotificationAdapter.cs +++ b/src/Core/Billing/Notifications/IPushNotificationAdapter.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -namespace Bit.Billing.Services; +namespace Bit.Core.Billing.Notifications; public interface IPushNotificationAdapter { diff --git a/src/Billing/Services/Implementations/PushNotificationAdapter.cs b/src/Core/Billing/Notifications/PushNotificationAdapter.cs similarity index 98% rename from src/Billing/Services/Implementations/PushNotificationAdapter.cs rename to src/Core/Billing/Notifications/PushNotificationAdapter.cs index 673ae1415eee..81a9244383c6 100644 --- a/src/Billing/Services/Implementations/PushNotificationAdapter.cs +++ b/src/Core/Billing/Notifications/PushNotificationAdapter.cs @@ -6,7 +6,7 @@ using Bit.Core.Models; using Bit.Core.Platform.Push; -namespace Bit.Billing.Services.Implementations; +namespace Bit.Core.Billing.Notifications; public class PushNotificationAdapter( IProviderUserRepository providerUserRepository, diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index 343a0e4f38e8..da8877a33ae8 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -2,6 +2,7 @@ #nullable disable using Bit.Core.Billing.Models; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; @@ -143,4 +144,20 @@ Task UpdateTaxInformation( /// if the gateway subscription ID is valid or empty; if the subscription doesn't exist in the gateway. /// Thrown when the is . Task IsValidGatewaySubscriptionIdAsync(ISubscriber subscriber); + + /// + /// Disables a subscriber based on the type. + /// For users, this disables premium. For organizations and providers, this disables the entity. + /// + /// The subscriber identifier (UserId, OrganizationId, or ProviderId). + /// The current billing period end date to set as the expiration date. + Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd); + + /// + /// Enables a subscriber based on the type. + /// For users, this enables premium. For organizations and providers, this enables the entity. + /// + /// The subscriber identifier (UserId, OrganizationId, or ProviderId). + /// The current billing period end date to set as the expiration date. + Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd); } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 7acbe200144d..9aa6e1f34d7b 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -3,18 +3,23 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Notifications; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Braintree; @@ -33,12 +38,17 @@ public class SubscriberService( IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, ILogger logger, + IOrganizationDisableCommand organizationDisableCommand, + IOrganizationEnableCommand organizationEnableCommand, IOrganizationRepository organizationRepository, IProviderRepository providerRepository, + IProviderService providerService, + IPushNotificationAdapter pushNotificationAdapter, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ITaxService taxService, - IUserRepository userRepository) : ISubscriberService + IUserRepository userRepository, + IUserService userService) : ISubscriberService { public async Task CancelSubscription( ISubscriber subscriber, @@ -817,6 +827,50 @@ public async Task IsValidGatewaySubscriptionIdAsync(ISubscriber subscriber } } + public Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => + subscriberId.Match( + userId => userService.DisablePremiumAsync(userId.Value, currentPeriodEnd), + async organizationId => + { + await organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd); + var organization = await organizationRepository.GetByIdAsync(organizationId.Value); + if (organization != null) + { + await pushNotificationAdapter.NotifyEnabledChangedAsync(organization); + } + }, + async providerId => + { + var provider = await providerRepository.GetByIdAsync(providerId.Value); + if (provider != null) + { + provider.Enabled = false; + await providerService.UpdateAsync(provider); + } + }); + + public Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => + subscriberId.Match( + userId => userService.EnablePremiumAsync(userId.Value, currentPeriodEnd), + async organizationId => + { + await organizationEnableCommand.EnableAsync(organizationId.Value, currentPeriodEnd); + var organization = await organizationRepository.GetByIdAsync(organizationId.Value); + if (organization != null) + { + await pushNotificationAdapter.NotifyEnabledChangedAsync(organization); + } + }, + async providerId => + { + var provider = await providerRepository.GetByIdAsync(providerId.Value); + if (provider != null) + { + provider.Enabled = true; + await providerService.UpdateAsync(provider); + } + }); + #region Shared Utilities private async Task AddBraintreeCustomerIdAsync( diff --git a/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs index a7aefe316384..976fd962b8ae 100644 --- a/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs +++ b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Services; using Bit.Core.Repositories; using NSubstitute; diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 6d74146b03dc..52a1b2a0edc5 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -2,11 +2,10 @@ using Bit.Billing.Services.Implementations; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -14,7 +13,6 @@ using Bit.Core.Test.Billing.Mocks.Plans; using Newtonsoft.Json.Linq; using NSubstitute; -using NSubstitute.ReturnsExtensions; using Stripe; using Xunit; using static Bit.Core.Billing.Constants.StripeConstants; @@ -30,13 +28,9 @@ public class SubscriptionUpdatedHandlerTests private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationEnableCommand _organizationEnableCommand; - private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; - private readonly IProviderRepository _providerRepository; - private readonly IProviderService _providerService; - private readonly IPushNotificationAdapter _pushNotificationAdapter; + private readonly ISubscriberService _subscriberService; + private readonly IOrganizationRepository _organizationRepository; private readonly SubscriptionUpdatedHandler _sut; public SubscriptionUpdatedHandlerTests() @@ -47,14 +41,9 @@ public SubscriptionUpdatedHandlerTests() _stripeFacade = Substitute.For(); _organizationSponsorshipRenewCommand = Substitute.For(); _userService = Substitute.For(); - _providerService = Substitute.For(); - _organizationRepository = Substitute.For(); - _organizationEnableCommand = Substitute.For(); - _organizationDisableCommand = Substitute.For(); _pricingClient = Substitute.For(); - _providerRepository = Substitute.For(); - _providerService = Substitute.For(); - _pushNotificationAdapter = Substitute.For(); + _subscriberService = Substitute.For(); + _organizationRepository = Substitute.For(); _sut = new SubscriptionUpdatedHandler( _stripeEventService, @@ -63,13 +52,9 @@ public SubscriptionUpdatedHandlerTests() _stripeFacade, _organizationSponsorshipRenewCommand, _userService, - _organizationRepository, - _organizationEnableCommand, - _organizationDisableCommand, _pricingClient, - _providerRepository, - _providerService, - _pushNotificationAdapter); + _subscriberService, + _organizationRepository); } [Fact] @@ -119,8 +104,6 @@ public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizatio _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _organizationRepository.GetByIdAsync(organizationId).Returns(organization); - var plan = new Enterprise2023Plan(true); _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); _pricingClient.ListPlans().Returns(MockPlans.Plans); @@ -129,10 +112,10 @@ public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizatio await _sut.HandleAsync(parsedEvent); // Assert - await _organizationDisableCommand.Received(1) - .DisableAsync(organizationId, currentPeriodEnd); - await _pushNotificationAdapter.Received(1) - .NotifyEnabledChangedAsync(organization); + await _subscriberService.Received(1) + .DisableSubscriberAsync( + Arg.Is(s => s.Match(_ => false, o => o.Value == organizationId, _ => false)), + currentPeriodEnd); await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => @@ -150,6 +133,7 @@ public async Task // Arrange var providerId = Guid.NewGuid(); var subscriptionId = "sub_test123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); var previousSubscription = new Subscription { @@ -165,7 +149,7 @@ public async Task { Data = [ - new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } ] }, Metadata = new Dictionary { ["providerId"] = providerId.ToString() }, @@ -182,17 +166,16 @@ public async Task } }; - var provider = new Provider { Id = providerId, Enabled = true }; - _stripeEventService.GetSubscription(parsedEvent, true, Arg.Any>()).Returns(currentSubscription); - _providerRepository.GetByIdAsync(providerId).Returns(provider); // Act await _sut.HandleAsync(parsedEvent); // Assert - Assert.False(provider.Enabled); - await _providerService.Received(1).UpdateAsync(provider); + await _subscriberService.Received(1) + .DisableSubscriberAsync( + Arg.Is(s => s.Match(_ => false, _ => false, p => p.Value == providerId)), + currentPeriodEnd); // Verify that UpdateSubscription was called with CancelAt await _stripeFacade.Received(1).UpdateSubscription( @@ -233,8 +216,6 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithoutValidTransition_ LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; - var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; - var parsedEvent = new Event { Data = new EventData @@ -247,15 +228,11 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithoutValidTransition_ _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _providerRepository.GetByIdAsync(providerId) - .Returns(provider); - // Act await _sut.HandleAsync(parsedEvent); // Assert - No disable or cancellation since there was no valid status transition - Assert.True(provider.Enabled); - await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); + await _subscriberService.DidNotReceive().DisableSubscriberAsync(Arg.Any(), Arg.Any()); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } @@ -288,8 +265,6 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithNonMatchingPrevious LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; - var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; - var parsedEvent = new Event { Data = new EventData @@ -302,15 +277,11 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithNonMatchingPrevious _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _providerRepository.GetByIdAsync(providerId) - .Returns(provider); - // Act await _sut.HandleAsync(parsedEvent); // Assert - No disable or cancellation since the previous status (Canceled) is not a valid transition source - Assert.True(provider.Enabled); - await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); + await _subscriberService.DidNotReceive().DisableSubscriberAsync(Arg.Any(), Arg.Any()); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } @@ -344,8 +315,6 @@ public async Task HandleAsync_ProviderSubscription_WithIncompleteExpiredStatus_D LatestInvoice = new Invoice { BillingReason = "renewal" } }; - var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; - var parsedEvent = new Event { Data = new EventData @@ -358,20 +327,17 @@ public async Task HandleAsync_ProviderSubscription_WithIncompleteExpiredStatus_D _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _providerRepository.GetByIdAsync(providerId) - .Returns(provider); - // Act await _sut.HandleAsync(parsedEvent); // Assert - IncompleteExpired status is not handled by the new logic - Assert.True(provider.Enabled); - await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); + await _subscriberService.DidNotReceive().DisableSubscriberAsync(Arg.Any(), Arg.Any()); + await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } [Fact] - public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_StillSetsCancellation() + public async Task HandleAsync_UnpaidProviderSubscription_StillSetsCancellation() { // Arrange var providerId = Guid.NewGuid(); @@ -411,14 +377,14 @@ public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_St _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _providerRepository.GetByIdAsync(providerId) - .Returns((Provider)null); - // Act await _sut.HandleAsync(parsedEvent); - // Assert - Provider not updated (since not found), but cancellation is still set - await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); + // Assert - DisableSubscriberAsync is called and cancellation is set + await _subscriberService.Received(1) + .DisableSubscriberAsync( + Arg.Is(s => s.Match(_ => false, _ => false, p => p.Value == providerId)), + currentPeriodEnd); await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => @@ -474,8 +440,10 @@ public async Task HandleAsync_UnpaidUserSubscription_DisablesPremiumAndSetsCance await _sut.HandleAsync(parsedEvent); // Assert - await _userService.Received(1) - .DisablePremiumAsync(userId, currentPeriodEnd); + await _subscriberService.Received(1) + .DisableSubscriberAsync( + Arg.Is(s => s.Match(u => u.Value == userId, _ => false, _ => false)), + currentPeriodEnd); await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => @@ -569,7 +537,6 @@ public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganization LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; - var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 }; var parsedEvent = new Event { Data = new EventData @@ -582,11 +549,8 @@ public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganization _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _organizationRepository.GetByIdAsync(organizationId) - .Returns(organization); - var plan = new Enterprise2023Plan(true); - _pricingClient.GetPlanOrThrow(organization.PlanType) + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2023) .Returns(plan); _pricingClient.ListPlans() .Returns(MockPlans.Plans); @@ -595,12 +559,12 @@ public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganization await _sut.HandleAsync(parsedEvent); // Assert - await _organizationEnableCommand.Received(1) - .EnableAsync(organizationId, currentPeriodEnd); + await _subscriberService.Received(1) + .EnableSubscriberAsync( + Arg.Is(s => s.Match(_ => false, o => o.Value == organizationId, _ => false)), + currentPeriodEnd); await _organizationService.Received(1) .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); - await _pushNotificationAdapter.Received(1) - .NotifyEnabledChangedAsync(organization); await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => @@ -653,8 +617,10 @@ public async Task HandleAsync_ActiveUserSubscription_EnablesPremiumAndUpdatesExp await _sut.HandleAsync(parsedEvent); // Assert - await _userService.Received(1) - .EnablePremiumAsync(userId, currentPeriodEnd); + await _subscriberService.Received(1) + .EnableSubscriberAsync( + Arg.Is(s => s.Match(u => u.Value == userId, _ => false, _ => false)), + currentPeriodEnd); await _userService.Received(1) .UpdatePremiumExpirationAsync(userId, currentPeriodEnd); await _stripeFacade.Received(1).UpdateSubscription( @@ -881,16 +847,13 @@ public async Task Subscription previousSubscription) { // Arrange - var (providerId, newSubscription, provider, parsedEvent) = + var (providerId, newSubscription, _, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _providerRepository - .GetByIdAsync(Arg.Any()) - .Returns(provider); _stripeFacade .UpdateSubscription(Arg.Any(), Arg.Any()) .Returns(newSubscription); @@ -902,12 +865,11 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository - .Received(1) - .GetByIdAsync(providerId); - await _providerService + await _subscriberService .Received(1) - .UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == true)); + .EnableSubscriberAsync( + Arg.Is(s => s.Match(_ => false, _ => false, p => p.Value == providerId)), + Arg.Any()); await _stripeFacade .Received(1) .UpdateSubscription(newSubscription.Id, @@ -922,15 +884,12 @@ public async Task { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Canceled }; - var (providerId, newSubscription, provider, parsedEvent) = + var (_, newSubscription, _, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _providerRepository - .GetByIdAsync(Arg.Any()) - .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -939,10 +898,7 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); - await _providerService - .DidNotReceive() - .UpdateAsync(Arg.Any()); + await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -954,15 +910,12 @@ public async Task { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Active }; - var (providerId, newSubscription, provider, parsedEvent) = + var (_, newSubscription, _, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _providerRepository - .GetByIdAsync(Arg.Any()) - .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -971,10 +924,7 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); - await _providerService - .DidNotReceive() - .UpdateAsync(Arg.Any()); + await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -986,15 +936,12 @@ public async Task { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Trialing }; - var (providerId, newSubscription, provider, parsedEvent) = + var (_, newSubscription, _, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _providerRepository - .GetByIdAsync(Arg.Any()) - .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -1003,10 +950,7 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); - await _providerService - .DidNotReceive() - .UpdateAsync(Arg.Any()); + await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -1018,15 +962,12 @@ public async Task { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.PastDue }; - var (providerId, newSubscription, provider, parsedEvent) = + var (_, newSubscription, _, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _providerRepository - .GetByIdAsync(Arg.Any()) - .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -1035,17 +976,14 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); - await _providerService - .DidNotReceive() - .UpdateAsync(Arg.Any()); + await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); } [Fact] - public async Task HandleAsync_ActiveProviderSubscriptionEvent_AndProviderDoesNotExist_NoChanges() + public async Task HandleAsync_ActiveProviderSubscriptionEvent_EnablesProviderViaSubscriberService() { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Unpaid }; @@ -1055,9 +993,6 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_AndProviderDoesNot _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _providerRepository - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); // Act await _sut.HandleAsync(parsedEvent); @@ -1066,15 +1001,14 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_AndProviderDoesNot await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository + await _subscriberService .Received(1) - .GetByIdAsync(providerId); - await _providerService - .DidNotReceive() - .UpdateAsync(Arg.Any()); + .EnableSubscriberAsync( + Arg.Is(s => s.Match(_ => false, _ => false, p => p.Value == providerId)), + Arg.Any()); await _stripeFacade - .DidNotReceive() - .UpdateSubscription(Arg.Any()); + .Received(1) + .UpdateSubscription(Arg.Any(), Arg.Any()); } [Fact] @@ -1082,15 +1016,12 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_WithNonMatchingPre { // Arrange - Using a previous status (Canceled) that doesn't trigger SubscriptionBecameActive var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Canceled }; - var (providerId, newSubscription, provider, parsedEvent) = + var (_, newSubscription, _, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); - _providerRepository - .GetByIdAsync(Arg.Any()) - .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -1099,13 +1030,10 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_WithNonMatchingPre await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); - await _providerService - .DidNotReceive() - .UpdateAsync(Arg.Any()); + await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); await _stripeFacade .DidNotReceive() - .UpdateSubscription(Arg.Any()); + .UpdateSubscription(Arg.Any(), Arg.Any()); } private static (Guid providerId, Subscription newSubscription, Provider provider, Event parsedEvent) diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 2f938065e57e..7a0a0581bcf5 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1,12 +1,19 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -1771,4 +1778,238 @@ public async Task IsValidGatewaySubscriptionIdAsync_InvalidSubscriptionId_Return } #endregion + + #region DisableSubscriberAsync + + [Theory, BitAutoData] + public async Task DisableSubscriberAsync_UserId_DisablesPremium( + SutProvider sutProvider) + { + // Arrange + var userId = Guid.NewGuid(); + var subscriberId = new UserId(userId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var userService = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await userService.Received(1).DisablePremiumAsync(userId, currentPeriodEnd); + } + + [Theory, BitAutoData] + public async Task DisableSubscriberAsync_OrganizationId_DisablesOrganizationAndNotifies( + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationId = organization.Id; + var subscriberId = new OrganizationId(organizationId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var organizationDisableCommand = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var pushNotificationAdapter = sutProvider.GetDependency(); + + organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + // Act + await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await organizationDisableCommand.Received(1).DisableAsync(organizationId, currentPeriodEnd); + await organizationRepository.Received(1).GetByIdAsync(organizationId); + await pushNotificationAdapter.Received(1).NotifyEnabledChangedAsync(organization); + } + + [Theory, BitAutoData] + public async Task DisableSubscriberAsync_OrganizationId_OrganizationNotFound_DoesNotNotify( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscriberId = new OrganizationId(organizationId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var organizationDisableCommand = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var pushNotificationAdapter = sutProvider.GetDependency(); + + organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); + + // Act + await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await organizationDisableCommand.Received(1).DisableAsync(organizationId, currentPeriodEnd); + await organizationRepository.Received(1).GetByIdAsync(organizationId); + await pushNotificationAdapter.DidNotReceive().NotifyEnabledChangedAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DisableSubscriberAsync_ProviderId_DisablesProvider( + Provider provider, + SutProvider sutProvider) + { + // Arrange + var providerId = provider.Id; + provider.Enabled = true; + var subscriberId = new ProviderId(providerId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var providerRepository = sutProvider.GetDependency(); + var providerService = sutProvider.GetDependency(); + + providerRepository.GetByIdAsync(providerId).Returns(provider); + + // Act + await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await providerRepository.Received(1).GetByIdAsync(providerId); + await providerService.Received(1).UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == false)); + } + + [Theory, BitAutoData] + public async Task DisableSubscriberAsync_ProviderId_ProviderNotFound_DoesNotUpdate( + SutProvider sutProvider) + { + // Arrange + var providerId = Guid.NewGuid(); + var subscriberId = new ProviderId(providerId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var providerRepository = sutProvider.GetDependency(); + var providerService = sutProvider.GetDependency(); + + providerRepository.GetByIdAsync(providerId).ReturnsNull(); + + // Act + await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await providerRepository.Received(1).GetByIdAsync(providerId); + await providerService.DidNotReceive().UpdateAsync(Arg.Any()); + } + + #endregion + + #region EnableSubscriberAsync + + [Theory, BitAutoData] + public async Task EnableSubscriberAsync_UserId_EnablesPremium( + SutProvider sutProvider) + { + // Arrange + var userId = Guid.NewGuid(); + var subscriberId = new UserId(userId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var userService = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await userService.Received(1).EnablePremiumAsync(userId, currentPeriodEnd); + } + + [Theory, BitAutoData] + public async Task EnableSubscriberAsync_OrganizationId_EnablesOrganizationAndNotifies( + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationId = organization.Id; + var subscriberId = new OrganizationId(organizationId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var organizationEnableCommand = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var pushNotificationAdapter = sutProvider.GetDependency(); + + organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + // Act + await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await organizationEnableCommand.Received(1).EnableAsync(organizationId, currentPeriodEnd); + await organizationRepository.Received(1).GetByIdAsync(organizationId); + await pushNotificationAdapter.Received(1).NotifyEnabledChangedAsync(organization); + } + + [Theory, BitAutoData] + public async Task EnableSubscriberAsync_OrganizationId_OrganizationNotFound_DoesNotNotify( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscriberId = new OrganizationId(organizationId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var organizationEnableCommand = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var pushNotificationAdapter = sutProvider.GetDependency(); + + organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); + + // Act + await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await organizationEnableCommand.Received(1).EnableAsync(organizationId, currentPeriodEnd); + await organizationRepository.Received(1).GetByIdAsync(organizationId); + await pushNotificationAdapter.DidNotReceive().NotifyEnabledChangedAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task EnableSubscriberAsync_ProviderId_EnablesProvider( + Provider provider, + SutProvider sutProvider) + { + // Arrange + var providerId = provider.Id; + provider.Enabled = false; + var subscriberId = new ProviderId(providerId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var providerRepository = sutProvider.GetDependency(); + var providerService = sutProvider.GetDependency(); + + providerRepository.GetByIdAsync(providerId).Returns(provider); + + // Act + await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await providerRepository.Received(1).GetByIdAsync(providerId); + await providerService.Received(1).UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == true)); + } + + [Theory, BitAutoData] + public async Task EnableSubscriberAsync_ProviderId_ProviderNotFound_DoesNotUpdate( + SutProvider sutProvider) + { + // Arrange + var providerId = Guid.NewGuid(); + var subscriberId = new ProviderId(providerId); + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var providerRepository = sutProvider.GetDependency(); + var providerService = sutProvider.GetDependency(); + + providerRepository.GetByIdAsync(providerId).ReturnsNull(); + + // Act + await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); + + // Assert + await providerRepository.Received(1).GetByIdAsync(providerId); + await providerService.DidNotReceive().UpdateAsync(Arg.Any()); + } + + #endregion } From 61dcf93617da857cc76d856e5773941ce9095cfb Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Wed, 4 Feb 2026 08:00:22 -0600 Subject: [PATCH 3/3] Revert "Move enable/disable operations to SubscriberService" This reverts commit fe0bc1516b86a44cfadc7c2cd31bbb269051046e. --- .../Services}/IPushNotificationAdapter.cs | 2 +- .../PaymentSucceededHandler.cs | 1 - .../PushNotificationAdapter.cs | 2 +- .../SetupIntentSucceededHandler.cs | 1 - .../SubscriptionUpdatedHandler.cs | 80 +++++- src/Billing/Startup.cs | 1 + .../Extensions/ServiceCollectionExtensions.cs | 2 - .../Billing/Services/ISubscriberService.cs | 17 -- .../Implementations/SubscriberService.cs | 56 +--- .../SetupIntentSucceededHandlerTests.cs | 1 - .../SubscriptionUpdatedHandlerTests.cs | 200 ++++++++++----- .../Services/SubscriberServiceTests.cs | 241 ------------------ 12 files changed, 210 insertions(+), 394 deletions(-) rename src/{Core/Billing/Notifications => Billing/Services}/IPushNotificationAdapter.cs (88%) rename src/{Core/Billing/Notifications => Billing/Services/Implementations}/PushNotificationAdapter.cs (98%) diff --git a/src/Core/Billing/Notifications/IPushNotificationAdapter.cs b/src/Billing/Services/IPushNotificationAdapter.cs similarity index 88% rename from src/Core/Billing/Notifications/IPushNotificationAdapter.cs rename to src/Billing/Services/IPushNotificationAdapter.cs index 7981c50428fa..2f74f35eecdc 100644 --- a/src/Core/Billing/Notifications/IPushNotificationAdapter.cs +++ b/src/Billing/Services/IPushNotificationAdapter.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -namespace Bit.Core.Billing.Notifications; +namespace Bit.Billing.Services; public interface IPushNotificationAdapter { diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 7b2eb554dba9..443227f7bfe3 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Pricing; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Core/Billing/Notifications/PushNotificationAdapter.cs b/src/Billing/Services/Implementations/PushNotificationAdapter.cs similarity index 98% rename from src/Core/Billing/Notifications/PushNotificationAdapter.cs rename to src/Billing/Services/Implementations/PushNotificationAdapter.cs index 81a9244383c6..673ae1415eee 100644 --- a/src/Core/Billing/Notifications/PushNotificationAdapter.cs +++ b/src/Billing/Services/Implementations/PushNotificationAdapter.cs @@ -6,7 +6,7 @@ using Bit.Core.Models; using Bit.Core.Platform.Push; -namespace Bit.Core.Billing.Notifications; +namespace Bit.Billing.Services.Implementations; public class PushNotificationAdapter( IProviderUserRepository providerUserRepository, diff --git a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs index 2324951ad822..89e40f0e438e 100644 --- a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs @@ -2,7 +2,6 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; -using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Services; using Bit.Core.Repositories; using OneOf; diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 1f2a3aaddf54..4507d9e30855 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,6 +1,8 @@ -using Bit.Core.Billing.Extensions; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; @@ -20,9 +22,13 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IPricingClient _pricingClient; - private readonly ISubscriberService _subscriberService; private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationEnableCommand _organizationEnableCommand; + private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly IPricingClient _pricingClient; + private readonly IProviderRepository _providerRepository; + private readonly IProviderService _providerService; + private readonly IPushNotificationAdapter _pushNotificationAdapter; public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, @@ -31,19 +37,29 @@ public SubscriptionUpdatedHandler( IStripeFacade stripeFacade, IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, IUserService userService, + IOrganizationRepository organizationRepository, + IOrganizationEnableCommand organizationEnableCommand, + IOrganizationDisableCommand organizationDisableCommand, IPricingClient pricingClient, - ISubscriberService subscriberService, - IOrganizationRepository organizationRepository) + IProviderRepository providerRepository, + IProviderService providerService, + IPushNotificationAdapter pushNotificationAdapter) { _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; _organizationService = organizationService; + _providerService = providerService; _stripeFacade = stripeFacade; _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand; _userService = userService; - _pricingClient = pricingClient; - _subscriberService = subscriberService; _organizationRepository = organizationRepository; + _providerRepository = providerRepository; + _organizationEnableCommand = organizationEnableCommand; + _organizationDisableCommand = organizationDisableCommand; + _pricingClient = pricingClient; + _providerRepository = providerRepository; + _providerService = providerService; + _pushNotificationAdapter = pushNotificationAdapter; } public async Task HandleAsync(Event parsedEvent) @@ -55,12 +71,12 @@ public async Task HandleAsync(Event parsedEvent) if (SubscriptionWentUnpaid(parsedEvent, subscription)) { - await _subscriberService.DisableSubscriberAsync(subscriberId, currentPeriodEnd); + await DisableSubscriberAsync(subscriberId, currentPeriodEnd); await SetSubscriptionToCancelAsync(subscription); } else if (SubscriptionBecameActive(parsedEvent, subscription)) { - await _subscriberService.EnableSubscriberAsync(subscriberId, currentPeriodEnd); + await EnableSubscriberAsync(subscriberId, currentPeriodEnd); await RemovePendingCancellationAsync(subscription); } @@ -109,6 +125,50 @@ SubscriptionStatus.Incomplete or LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle }; + private Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => + subscriberId.Match( + userId => _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd), + async organizationId => + { + await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd); + var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + if (organization != null) + { + await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); + } + }, + async providerId => + { + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + if (provider != null) + { + provider.Enabled = false; + await _providerService.UpdateAsync(provider); + } + }); + + private Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => + subscriberId.Match( + userId => _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd), + async organizationId => + { + await _organizationEnableCommand.EnableAsync(organizationId.Value, currentPeriodEnd); + var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + if (organization != null) + { + await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); + } + }, + async providerId => + { + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + if (provider != null) + { + provider.Enabled = true; + await _providerService.UpdateAsync(provider); + } + }); + private async Task SetSubscriptionToCancelAsync(Subscription subscription) { if (subscription.TestClock != null) diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 6e2b93563d35..f5f98bfd53c7 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -101,6 +101,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Add Quartz services first services.AddQuartz(q => diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index db77f3c4121c..ddf3479aa36f 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -2,7 +2,6 @@ using Bit.Core.Billing.Caches.Implementations; using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Licenses.Extensions; -using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Services; @@ -32,7 +31,6 @@ public static void AddBillingOperations(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddLicenseServices(); services.AddLicenseOperations(); services.AddPricingClient(); diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index da8877a33ae8..343a0e4f38e8 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -2,7 +2,6 @@ #nullable disable using Bit.Core.Billing.Models; -using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; @@ -144,20 +143,4 @@ Task UpdateTaxInformation( /// if the gateway subscription ID is valid or empty; if the subscription doesn't exist in the gateway. /// Thrown when the is . Task IsValidGatewaySubscriptionIdAsync(ISubscriber subscriber); - - /// - /// Disables a subscriber based on the type. - /// For users, this disables premium. For organizations and providers, this disables the entity. - /// - /// The subscriber identifier (UserId, OrganizationId, or ProviderId). - /// The current billing period end date to set as the expiration date. - Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd); - - /// - /// Enables a subscriber based on the type. - /// For users, this enables premium. For organizations and providers, this enables the entity. - /// - /// The subscriber identifier (UserId, OrganizationId, or ProviderId). - /// The current billing period end date to set as the expiration date. - Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd); } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 9aa6e1f34d7b..7acbe200144d 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -3,23 +3,18 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Notifications; -using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Braintree; @@ -38,17 +33,12 @@ public class SubscriberService( IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, ILogger logger, - IOrganizationDisableCommand organizationDisableCommand, - IOrganizationEnableCommand organizationEnableCommand, IOrganizationRepository organizationRepository, IProviderRepository providerRepository, - IProviderService providerService, - IPushNotificationAdapter pushNotificationAdapter, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ITaxService taxService, - IUserRepository userRepository, - IUserService userService) : ISubscriberService + IUserRepository userRepository) : ISubscriberService { public async Task CancelSubscription( ISubscriber subscriber, @@ -827,50 +817,6 @@ public async Task IsValidGatewaySubscriptionIdAsync(ISubscriber subscriber } } - public Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => - subscriberId.Match( - userId => userService.DisablePremiumAsync(userId.Value, currentPeriodEnd), - async organizationId => - { - await organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd); - var organization = await organizationRepository.GetByIdAsync(organizationId.Value); - if (organization != null) - { - await pushNotificationAdapter.NotifyEnabledChangedAsync(organization); - } - }, - async providerId => - { - var provider = await providerRepository.GetByIdAsync(providerId.Value); - if (provider != null) - { - provider.Enabled = false; - await providerService.UpdateAsync(provider); - } - }); - - public Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => - subscriberId.Match( - userId => userService.EnablePremiumAsync(userId.Value, currentPeriodEnd), - async organizationId => - { - await organizationEnableCommand.EnableAsync(organizationId.Value, currentPeriodEnd); - var organization = await organizationRepository.GetByIdAsync(organizationId.Value); - if (organization != null) - { - await pushNotificationAdapter.NotifyEnabledChangedAsync(organization); - } - }, - async providerId => - { - var provider = await providerRepository.GetByIdAsync(providerId.Value); - if (provider != null) - { - provider.Enabled = true; - await providerService.UpdateAsync(provider); - } - }); - #region Shared Utilities private async Task AddBraintreeCustomerIdAsync( diff --git a/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs index 976fd962b8ae..a7aefe316384 100644 --- a/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs +++ b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; -using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Services; using Bit.Core.Repositories; using NSubstitute; diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index f4da9f8066aa..1807050b31ae 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -2,10 +2,11 @@ using Bit.Billing.Services.Implementations; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -13,6 +14,7 @@ using Bit.Core.Test.Billing.Mocks.Plans; using Newtonsoft.Json.Linq; using NSubstitute; +using NSubstitute.ReturnsExtensions; using Stripe; using Xunit; using static Bit.Core.Billing.Constants.StripeConstants; @@ -28,9 +30,13 @@ public class SubscriptionUpdatedHandlerTests private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IPricingClient _pricingClient; - private readonly ISubscriberService _subscriberService; private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationEnableCommand _organizationEnableCommand; + private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly IPricingClient _pricingClient; + private readonly IProviderRepository _providerRepository; + private readonly IProviderService _providerService; + private readonly IPushNotificationAdapter _pushNotificationAdapter; private readonly SubscriptionUpdatedHandler _sut; public SubscriptionUpdatedHandlerTests() @@ -41,9 +47,14 @@ public SubscriptionUpdatedHandlerTests() _stripeFacade = Substitute.For(); _organizationSponsorshipRenewCommand = Substitute.For(); _userService = Substitute.For(); - _pricingClient = Substitute.For(); - _subscriberService = Substitute.For(); + _providerService = Substitute.For(); _organizationRepository = Substitute.For(); + _organizationEnableCommand = Substitute.For(); + _organizationDisableCommand = Substitute.For(); + _pricingClient = Substitute.For(); + _providerRepository = Substitute.For(); + _providerService = Substitute.For(); + _pushNotificationAdapter = Substitute.For(); _sut = new SubscriptionUpdatedHandler( _stripeEventService, @@ -52,9 +63,13 @@ public SubscriptionUpdatedHandlerTests() _stripeFacade, _organizationSponsorshipRenewCommand, _userService, + _organizationRepository, + _organizationEnableCommand, + _organizationDisableCommand, _pricingClient, - _subscriberService, - _organizationRepository); + _providerRepository, + _providerService, + _pushNotificationAdapter); } [Fact] @@ -104,6 +119,8 @@ public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizatio _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + var plan = new Enterprise2023Plan(true); _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); _pricingClient.ListPlans().Returns(MockPlans.Plans); @@ -112,10 +129,10 @@ public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizatio await _sut.HandleAsync(parsedEvent); // Assert - await _subscriberService.Received(1) - .DisableSubscriberAsync( - Arg.Is(s => s.Match(_ => false, o => o.Value == organizationId, _ => false)), - currentPeriodEnd); + await _organizationDisableCommand.Received(1) + .DisableAsync(organizationId, currentPeriodEnd); + await _pushNotificationAdapter.Received(1) + .NotifyEnabledChangedAsync(organization); await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => @@ -133,7 +150,6 @@ public async Task // Arrange var providerId = Guid.NewGuid(); var subscriptionId = "sub_test123"; - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); var previousSubscription = new Subscription { @@ -149,7 +165,7 @@ public async Task { Data = [ - new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } ] }, Metadata = new Dictionary { ["providerId"] = providerId.ToString() }, @@ -166,16 +182,17 @@ public async Task } }; + var provider = new Provider { Id = providerId, Enabled = true }; + _stripeEventService.GetSubscription(parsedEvent, true, Arg.Any>()).Returns(currentSubscription); + _providerRepository.GetByIdAsync(providerId).Returns(provider); // Act await _sut.HandleAsync(parsedEvent); // Assert - await _subscriberService.Received(1) - .DisableSubscriberAsync( - Arg.Is(s => s.Match(_ => false, _ => false, p => p.Value == providerId)), - currentPeriodEnd); + Assert.False(provider.Enabled); + await _providerService.Received(1).UpdateAsync(provider); // Verify that UpdateSubscription was called with CancelAt await _stripeFacade.Received(1).UpdateSubscription( @@ -216,6 +233,8 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithoutValidTransition_ LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; + var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; + var parsedEvent = new Event { Data = new EventData @@ -228,11 +247,15 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithoutValidTransition_ _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _providerRepository.GetByIdAsync(providerId) + .Returns(provider); + // Act await _sut.HandleAsync(parsedEvent); // Assert - No disable or cancellation since there was no valid status transition - await _subscriberService.DidNotReceive().DisableSubscriberAsync(Arg.Any(), Arg.Any()); + Assert.True(provider.Enabled); + await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } @@ -265,6 +288,8 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithNonMatchingPrevious LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; + var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; + var parsedEvent = new Event { Data = new EventData @@ -277,11 +302,15 @@ public async Task HandleAsync_UnpaidProviderSubscription_WithNonMatchingPrevious _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _providerRepository.GetByIdAsync(providerId) + .Returns(provider); + // Act await _sut.HandleAsync(parsedEvent); // Assert - No disable or cancellation since the previous status (Canceled) is not a valid transition source - await _subscriberService.DidNotReceive().DisableSubscriberAsync(Arg.Any(), Arg.Any()); + Assert.True(provider.Enabled); + await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } @@ -315,6 +344,8 @@ public async Task HandleAsync_ProviderSubscription_WithIncompleteExpiredStatus_D LatestInvoice = new Invoice { BillingReason = "renewal" } }; + var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; + var parsedEvent = new Event { Data = new EventData @@ -327,17 +358,20 @@ public async Task HandleAsync_ProviderSubscription_WithIncompleteExpiredStatus_D _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _providerRepository.GetByIdAsync(providerId) + .Returns(provider); + // Act await _sut.HandleAsync(parsedEvent); // Assert - IncompleteExpired status is not handled by the new logic - await _subscriberService.DidNotReceive().DisableSubscriberAsync(Arg.Any(), Arg.Any()); - await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); + Assert.True(provider.Enabled); + await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } [Fact] - public async Task HandleAsync_UnpaidProviderSubscription_StillSetsCancellation() + public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_StillSetsCancellation() { // Arrange var providerId = Guid.NewGuid(); @@ -377,14 +411,14 @@ public async Task HandleAsync_UnpaidProviderSubscription_StillSetsCancellation() _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _providerRepository.GetByIdAsync(providerId) + .Returns((Provider)null); + // Act await _sut.HandleAsync(parsedEvent); - // Assert - DisableSubscriberAsync is called and cancellation is set - await _subscriberService.Received(1) - .DisableSubscriberAsync( - Arg.Is(s => s.Match(_ => false, _ => false, p => p.Value == providerId)), - currentPeriodEnd); + // Assert - Provider not updated (since not found), but cancellation is still set + await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => @@ -440,10 +474,8 @@ public async Task HandleAsync_UnpaidUserSubscription_DisablesPremiumAndSetsCance await _sut.HandleAsync(parsedEvent); // Assert - await _subscriberService.Received(1) - .DisableSubscriberAsync( - Arg.Is(s => s.Match(u => u.Value == userId, _ => false, _ => false)), - currentPeriodEnd); + await _userService.Received(1) + .DisablePremiumAsync(userId, currentPeriodEnd); await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => @@ -537,6 +569,7 @@ public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganization LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } }; + var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 }; var parsedEvent = new Event { Data = new EventData @@ -549,8 +582,11 @@ public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganization _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + var plan = new Enterprise2023Plan(true); - _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2023) + _pricingClient.GetPlanOrThrow(organization.PlanType) .Returns(plan); _pricingClient.ListPlans() .Returns(MockPlans.Plans); @@ -559,12 +595,12 @@ public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganization await _sut.HandleAsync(parsedEvent); // Assert - await _subscriberService.Received(1) - .EnableSubscriberAsync( - Arg.Is(s => s.Match(_ => false, o => o.Value == organizationId, _ => false)), - currentPeriodEnd); + await _organizationEnableCommand.Received(1) + .EnableAsync(organizationId, currentPeriodEnd); await _organizationService.Received(1) .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); + await _pushNotificationAdapter.Received(1) + .NotifyEnabledChangedAsync(organization); await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => @@ -617,10 +653,8 @@ public async Task HandleAsync_ActiveUserSubscription_EnablesPremiumAndUpdatesExp await _sut.HandleAsync(parsedEvent); // Assert - await _subscriberService.Received(1) - .EnableSubscriberAsync( - Arg.Is(s => s.Match(u => u.Value == userId, _ => false, _ => false)), - currentPeriodEnd); + await _userService.Received(1) + .EnablePremiumAsync(userId, currentPeriodEnd); await _userService.Received(1) .UpdatePremiumExpirationAsync(userId, currentPeriodEnd); await _stripeFacade.Received(1).UpdateSubscription( @@ -847,13 +881,16 @@ public async Task Subscription previousSubscription) { // Arrange - var (providerId, newSubscription, _, parsedEvent) = + var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); + _providerRepository + .GetByIdAsync(Arg.Any()) + .Returns(provider); _stripeFacade .UpdateSubscription(Arg.Any(), Arg.Any()) .Returns(newSubscription); @@ -865,11 +902,12 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _subscriberService + await _providerRepository + .Received(1) + .GetByIdAsync(providerId); + await _providerService .Received(1) - .EnableSubscriberAsync( - Arg.Is(s => s.Match(_ => false, _ => false, p => p.Value == providerId)), - Arg.Any()); + .UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == true)); await _stripeFacade .Received(1) .UpdateSubscription(newSubscription.Id, @@ -884,12 +922,15 @@ public async Task { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Canceled }; - var (_, newSubscription, _, parsedEvent) = + var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); + _providerRepository + .GetByIdAsync(Arg.Any()) + .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -898,7 +939,10 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerService + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -910,12 +954,15 @@ public async Task { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Active }; - var (_, newSubscription, _, parsedEvent) = + var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); + _providerRepository + .GetByIdAsync(Arg.Any()) + .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -924,7 +971,10 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerService + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -936,12 +986,15 @@ public async Task { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Trialing }; - var (_, newSubscription, _, parsedEvent) = + var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); + _providerRepository + .GetByIdAsync(Arg.Any()) + .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -950,7 +1003,10 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerService + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); @@ -962,12 +1018,15 @@ public async Task { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.PastDue }; - var (_, newSubscription, _, parsedEvent) = + var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); + _providerRepository + .GetByIdAsync(Arg.Any()) + .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -976,14 +1035,17 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerService + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); } [Fact] - public async Task HandleAsync_ActiveProviderSubscriptionEvent_EnablesProviderViaSubscriberService() + public async Task HandleAsync_ActiveProviderSubscriptionEvent_AndProviderDoesNotExist_NoChanges() { // Arrange var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Unpaid }; @@ -993,6 +1055,9 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_EnablesProviderVia _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); + _providerRepository + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); // Act await _sut.HandleAsync(parsedEvent); @@ -1001,14 +1066,15 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_EnablesProviderVia await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _subscriberService + await _providerRepository .Received(1) - .EnableSubscriberAsync( - Arg.Is(s => s.Match(_ => false, _ => false, p => p.Value == providerId)), - Arg.Any()); + .GetByIdAsync(providerId); + await _providerService + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade - .Received(1) - .UpdateSubscription(Arg.Any(), Arg.Any()); + .DidNotReceive() + .UpdateSubscription(Arg.Any()); } [Fact] @@ -1016,12 +1082,15 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_WithNonMatchingPre { // Arrange - Using a previous status (Canceled) that doesn't trigger SubscriptionBecameActive var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Canceled }; - var (_, newSubscription, _, parsedEvent) = + var (providerId, newSubscription, provider, parsedEvent) = CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription); _stripeEventService .GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(newSubscription); + _providerRepository + .GetByIdAsync(Arg.Any()) + .Returns(provider); // Act await _sut.HandleAsync(parsedEvent); @@ -1030,10 +1099,13 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_WithNonMatchingPre await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _subscriberService.DidNotReceive().EnableSubscriberAsync(Arg.Any(), Arg.Any()); + await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerService + .DidNotReceive() + .UpdateAsync(Arg.Any()); await _stripeFacade .DidNotReceive() - .UpdateSubscription(Arg.Any(), Arg.Any()); + .UpdateSubscription(Arg.Any()); } private static (Guid providerId, Subscription newSubscription, Provider provider, Event parsedEvent) diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 7a0a0581bcf5..2f938065e57e 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1,19 +1,12 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Notifications; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; -using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Enums; -using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -1778,238 +1771,4 @@ public async Task IsValidGatewaySubscriptionIdAsync_InvalidSubscriptionId_Return } #endregion - - #region DisableSubscriberAsync - - [Theory, BitAutoData] - public async Task DisableSubscriberAsync_UserId_DisablesPremium( - SutProvider sutProvider) - { - // Arrange - var userId = Guid.NewGuid(); - var subscriberId = new UserId(userId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var userService = sutProvider.GetDependency(); - - // Act - await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await userService.Received(1).DisablePremiumAsync(userId, currentPeriodEnd); - } - - [Theory, BitAutoData] - public async Task DisableSubscriberAsync_OrganizationId_DisablesOrganizationAndNotifies( - Organization organization, - SutProvider sutProvider) - { - // Arrange - var organizationId = organization.Id; - var subscriberId = new OrganizationId(organizationId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var organizationDisableCommand = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var pushNotificationAdapter = sutProvider.GetDependency(); - - organizationRepository.GetByIdAsync(organizationId).Returns(organization); - - // Act - await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await organizationDisableCommand.Received(1).DisableAsync(organizationId, currentPeriodEnd); - await organizationRepository.Received(1).GetByIdAsync(organizationId); - await pushNotificationAdapter.Received(1).NotifyEnabledChangedAsync(organization); - } - - [Theory, BitAutoData] - public async Task DisableSubscriberAsync_OrganizationId_OrganizationNotFound_DoesNotNotify( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var subscriberId = new OrganizationId(organizationId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var organizationDisableCommand = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var pushNotificationAdapter = sutProvider.GetDependency(); - - organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); - - // Act - await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await organizationDisableCommand.Received(1).DisableAsync(organizationId, currentPeriodEnd); - await organizationRepository.Received(1).GetByIdAsync(organizationId); - await pushNotificationAdapter.DidNotReceive().NotifyEnabledChangedAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task DisableSubscriberAsync_ProviderId_DisablesProvider( - Provider provider, - SutProvider sutProvider) - { - // Arrange - var providerId = provider.Id; - provider.Enabled = true; - var subscriberId = new ProviderId(providerId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var providerRepository = sutProvider.GetDependency(); - var providerService = sutProvider.GetDependency(); - - providerRepository.GetByIdAsync(providerId).Returns(provider); - - // Act - await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await providerRepository.Received(1).GetByIdAsync(providerId); - await providerService.Received(1).UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == false)); - } - - [Theory, BitAutoData] - public async Task DisableSubscriberAsync_ProviderId_ProviderNotFound_DoesNotUpdate( - SutProvider sutProvider) - { - // Arrange - var providerId = Guid.NewGuid(); - var subscriberId = new ProviderId(providerId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var providerRepository = sutProvider.GetDependency(); - var providerService = sutProvider.GetDependency(); - - providerRepository.GetByIdAsync(providerId).ReturnsNull(); - - // Act - await sutProvider.Sut.DisableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await providerRepository.Received(1).GetByIdAsync(providerId); - await providerService.DidNotReceive().UpdateAsync(Arg.Any()); - } - - #endregion - - #region EnableSubscriberAsync - - [Theory, BitAutoData] - public async Task EnableSubscriberAsync_UserId_EnablesPremium( - SutProvider sutProvider) - { - // Arrange - var userId = Guid.NewGuid(); - var subscriberId = new UserId(userId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var userService = sutProvider.GetDependency(); - - // Act - await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await userService.Received(1).EnablePremiumAsync(userId, currentPeriodEnd); - } - - [Theory, BitAutoData] - public async Task EnableSubscriberAsync_OrganizationId_EnablesOrganizationAndNotifies( - Organization organization, - SutProvider sutProvider) - { - // Arrange - var organizationId = organization.Id; - var subscriberId = new OrganizationId(organizationId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var organizationEnableCommand = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var pushNotificationAdapter = sutProvider.GetDependency(); - - organizationRepository.GetByIdAsync(organizationId).Returns(organization); - - // Act - await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await organizationEnableCommand.Received(1).EnableAsync(organizationId, currentPeriodEnd); - await organizationRepository.Received(1).GetByIdAsync(organizationId); - await pushNotificationAdapter.Received(1).NotifyEnabledChangedAsync(organization); - } - - [Theory, BitAutoData] - public async Task EnableSubscriberAsync_OrganizationId_OrganizationNotFound_DoesNotNotify( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var subscriberId = new OrganizationId(organizationId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var organizationEnableCommand = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var pushNotificationAdapter = sutProvider.GetDependency(); - - organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); - - // Act - await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await organizationEnableCommand.Received(1).EnableAsync(organizationId, currentPeriodEnd); - await organizationRepository.Received(1).GetByIdAsync(organizationId); - await pushNotificationAdapter.DidNotReceive().NotifyEnabledChangedAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task EnableSubscriberAsync_ProviderId_EnablesProvider( - Provider provider, - SutProvider sutProvider) - { - // Arrange - var providerId = provider.Id; - provider.Enabled = false; - var subscriberId = new ProviderId(providerId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var providerRepository = sutProvider.GetDependency(); - var providerService = sutProvider.GetDependency(); - - providerRepository.GetByIdAsync(providerId).Returns(provider); - - // Act - await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await providerRepository.Received(1).GetByIdAsync(providerId); - await providerService.Received(1).UpdateAsync(Arg.Is(p => p.Id == providerId && p.Enabled == true)); - } - - [Theory, BitAutoData] - public async Task EnableSubscriberAsync_ProviderId_ProviderNotFound_DoesNotUpdate( - SutProvider sutProvider) - { - // Arrange - var providerId = Guid.NewGuid(); - var subscriberId = new ProviderId(providerId); - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var providerRepository = sutProvider.GetDependency(); - var providerService = sutProvider.GetDependency(); - - providerRepository.GetByIdAsync(providerId).ReturnsNull(); - - // Act - await sutProvider.Sut.EnableSubscriberAsync(subscriberId, currentPeriodEnd); - - // Assert - await providerRepository.Received(1).GetByIdAsync(providerId); - await providerService.DidNotReceive().UpdateAsync(Arg.Any()); - } - - #endregion }