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 0e1ce8e1f03a..1807050b31ae 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,7 +799,7 @@ public async Task var subscription = new Subscription { Id = "sub_123", - Status = StripeSubscriptionStatus.Active, + Status = SubscriptionStatus.Active, CustomerId = "cus_123", Items = new StripeList { @@ -752,8 +846,8 @@ 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 { @@ -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 } } }; } }