diff --git a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs index 0db498844e3a..21007a26425f 100644 --- a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs +++ b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs @@ -40,8 +40,8 @@ public async Task HandleAsync(Event parsedEvent) Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", BillingReason: - "subscription_cycle" or - "automatic_pending_invoice_item_invoice", + StripeConstants.BillingReasons.SubscriptionCycle or + StripeConstants.BillingReasons.AutomaticPendingInvoiceItemInvoice, Parent.SubscriptionDetails.Subscription: not null }) { diff --git a/src/Billing/Services/Implementations/PaymentFailedHandler.cs b/src/Billing/Services/Implementations/PaymentFailedHandler.cs index 0da6d03e9426..b57690d3936e 100644 --- a/src/Billing/Services/Implementations/PaymentFailedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentFailedHandler.cs @@ -50,7 +50,7 @@ invoice is AmountDue: > 0, Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", - BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice", + BillingReason: StripeConstants.BillingReasons.SubscriptionCycle or StripeConstants.BillingReasons.AutomaticPendingInvoiceItemInvoice, Parent.SubscriptionDetails: not null }; } diff --git a/src/Billing/Services/Implementations/StripeEventUtilityService.cs b/src/Billing/Services/Implementations/StripeEventUtilityService.cs index 53512427c03f..e34c081caec9 100644 --- a/src/Billing/Services/Implementations/StripeEventUtilityService.cs +++ b/src/Billing/Services/Implementations/StripeEventUtilityService.cs @@ -258,7 +258,7 @@ invoice is AmountDue: > 0, Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", - BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice", + BillingReason: StripeConstants.BillingReasons.SubscriptionCycle or StripeConstants.BillingReasons.AutomaticPendingInvoiceItemInvoice, Parent.SubscriptionDetails: not null }; diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index cafc328f078e..7ee0b069ac79 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,4 +1,6 @@ using Bit.Core; +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; @@ -8,6 +10,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Entities; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -83,39 +86,73 @@ public async Task HandleAsync(Event parsedEvent) var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]); SubscriberId subscriberId = subscription; + var subscriber = await GetSubscriberAsync(subscriberId); + if (subscriber == null) + { + _logger.LogWarning( + "Subscriber not found for subscription ({SubscriptionId}) in event ({EventId}), skipping handler", + subscription.Id, + parsedEvent.Id); + return; + } + var currentPeriodEnd = subscription.GetCurrentPeriodEnd(); + var clearOrgBillingAutomationExemption = false; - if (SubscriptionWentUnpaid(parsedEvent, subscription) || - SubscriptionWentIncompleteExpired(parsedEvent, subscription)) + if (SubscriptionWentUnpaid(parsedEvent, subscription)) + { + if (SkipUnpaidBillingAutomationsForExemptOrganization(subscriber)) + { + _logger.LogInformation( + "Skipping billing automations for exempt organization ({OrganizationId}). Exemption will be cleared after handler completion", + subscriber.Id); + clearOrgBillingAutomationExemption = true; + } + else + { + await DisableSubscriberAsync(subscriber, currentPeriodEnd); + await SetSubscriptionToCancelAsync(subscription); + } + } + else if (SubscriptionWentIncompleteExpired(parsedEvent, subscription)) { - await DisableSubscriberAsync(subscriberId, currentPeriodEnd); + await DisableSubscriberAsync(subscriber, currentPeriodEnd); await SetSubscriptionToCancelAsync(subscription); } else if (SubscriptionBecameActive(parsedEvent, subscription)) { - await EnableSubscriberAsync(subscriberId, currentPeriodEnd); + await EnableSubscriberAsync(subscriber, currentPeriodEnd); await RemovePendingCancellationAsync(subscription); } - await subscriberId.Match( - userId => _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd), - async organizationId => - { - if (_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + switch (subscriber) + { + case User user: + await _userService.UpdatePremiumExpirationAsync(user.Id, currentPeriodEnd); + break; + case Organization organization: { - await HandleScheduleTriggeredFamiliesMigrationAsync(parsedEvent, subscription, organizationId.Value); - } + if (_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + { + await HandleScheduleTriggeredFamiliesMigrationAsync(parsedEvent, subscription, organization); + } - await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd); + await _organizationService.UpdateExpirationDateAsync(organization.Id, currentPeriodEnd); - if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue) - { - await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value); - } + if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue) + { + await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organization.Id, currentPeriodEnd.Value); + } - await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription); - }, - _ => Task.CompletedTask); + await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription, organization); + + if (clearOrgBillingAutomationExemption) + { + await ClearBillingAutomationExemptionAsync(organization); + } + break; + } + } } private static bool SubscriptionWentUnpaid( @@ -130,7 +167,7 @@ SubscriptionStatus.Active or } && currentSubscription is { Status: SubscriptionStatus.Unpaid, - LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle + LatestInvoice.BillingReason: BillingReasons.SubscriptionCycle }; private static bool SubscriptionWentIncompleteExpired( @@ -159,65 +196,79 @@ SubscriptionStatus.Incomplete or LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle }; - private Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => - subscriberId.Match( - async userId => - { - await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); - var user = await _userRepository.GetByIdAsync(userId.Value); - if (user != null) - { - await _pushNotificationAdapter.NotifyPremiumStatusChangedAsync(user); - } - }, - 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 static bool SkipUnpaidBillingAutomationsForExemptOrganization(ISubscriber subscriber) => + subscriber is Organization { Enabled: true, ExemptFromBillingAutomation: true }; - private Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) => - subscriberId.Match( - async userId => - { - await _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd); - var user = await _userRepository.GetByIdAsync(userId.Value); - if (user != null) - { - await _pushNotificationAdapter.NotifyPremiumStatusChangedAsync(user!); - } - }, - 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 Task GetSubscriberAsync(SubscriberId subscriberId) => + subscriberId.Match>( + async userId => await _userRepository.GetByIdAsync(userId.Value), + async organizationId => await _organizationRepository.GetByIdAsync(organizationId.Value), + async providerId => await _providerRepository.GetByIdAsync(providerId.Value)); + + private Task DisableSubscriberAsync(ISubscriber subscriber, DateTime? currentPeriodEnd) => + subscriber switch + { + User user => DisableUserAsync(user, currentPeriodEnd), + Organization organization => DisableOrganizationAsync(organization, currentPeriodEnd), + Provider provider => DisableProviderAsync(provider), + _ => Task.CompletedTask + }; + + private async Task DisableUserAsync(User user, DateTime? currentPeriodEnd) + { + await _userService.DisablePremiumAsync(user.Id, currentPeriodEnd); + await _pushNotificationAdapter.NotifyPremiumStatusChangedAsync(user); + } + + private async Task DisableOrganizationAsync(Organization organization, DateTime? currentPeriodEnd) + { + await _organizationDisableCommand.DisableAsync(organization.Id, currentPeriodEnd); + await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); + } + + private async Task DisableProviderAsync(Provider provider) + { + provider.Enabled = false; + await _providerService.UpdateAsync(provider); + } + + private async Task ClearBillingAutomationExemptionAsync(Organization organization) + { + organization.ExemptFromBillingAutomation = false; + organization.RevisionDate = DateTime.UtcNow; + await _organizationRepository.ReplaceAsync(organization); + + _logger.LogInformation( + "Exemption has been cleared for organization ({OrganizationId})", + organization.Id); + } + + private Task EnableSubscriberAsync(ISubscriber subscriber, DateTime? currentPeriodEnd) => + subscriber switch + { + User user => EnableUserAsync(user, currentPeriodEnd), + Organization organization => EnableOrganizationAsync(organization, currentPeriodEnd), + Provider provider => EnableProviderAsync(provider), + _ => Task.CompletedTask + }; + + private async Task EnableUserAsync(User user, DateTime? currentPeriodEnd) + { + await _userService.EnablePremiumAsync(user.Id, currentPeriodEnd); + await _pushNotificationAdapter.NotifyPremiumStatusChangedAsync(user); + } + + private async Task EnableOrganizationAsync(Organization organization, DateTime? currentPeriodEnd) + { + await _organizationEnableCommand.EnableAsync(organization.Id, currentPeriodEnd); + await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); + } + + private async Task EnableProviderAsync(Provider provider) + { + provider.Enabled = true; + await _providerService.UpdateAsync(provider); + } private async Task SetSubscriptionToCancelAsync(Subscription subscription) { @@ -258,24 +309,17 @@ private async Task RemovePendingCancellationAsync(Subscription subscription) /// /// /// + /// private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync( Event parsedEvent, - Subscription subscription) + Subscription subscription, + Organization organization) { if (parsedEvent.Data.PreviousAttributes?.items is null) { return; } - var organization = subscription.Metadata.TryGetValue("organizationId", out var organizationId) - ? await _organizationRepository.GetByIdAsync(Guid.Parse(organizationId)) - : null; - - if (organization == null) - { - return; - } - var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); if (!plan.SupportsSecretsManager) @@ -346,7 +390,7 @@ private async Task WaitForTestClockToAdvanceAsync(TestClock testClock) private async Task HandleScheduleTriggeredFamiliesMigrationAsync( Event parsedEvent, Subscription subscription, - Guid organizationId) + Organization organization) { try { @@ -388,15 +432,6 @@ private async Task HandleScheduleTriggeredFamiliesMigrationAsync( } // Sync org DB to match the plan Stripe already transitioned to - var organization = await _organizationRepository.GetByIdAsync(organizationId); - if (organization == null) - { - _logger.LogWarning( - "Organization ({OrganizationId}) not found for schedule-triggered Families migration", - organizationId); - return; - } - organization.PlanType = familiesPlan.Type; organization.Plan = familiesPlan.Name; organization.UsersGetPremium = familiesPlan.UsersGetPremium; @@ -406,7 +441,7 @@ private async Task HandleScheduleTriggeredFamiliesMigrationAsync( _logger.LogInformation( "Updated organization ({OrganizationId}) to FamiliesAnnually plan after schedule transition", - organizationId); + organization.Id); } catch (BillingException) { @@ -421,7 +456,7 @@ private async Task HandleScheduleTriggeredFamiliesMigrationAsync( _logger.LogError( exception, "Failed to handle schedule-triggered Families migration for organization ({OrganizationId})", - organizationId); + organization.Id); } } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index a1b775072f05..43d2c7318506 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -14,8 +14,10 @@ public static class AutomaticTaxStatus public static class BillingReasons { + public const string AutomaticPendingInvoiceItemInvoice = "automatic_pending_invoice_item_invoice"; public const string SubscriptionCreate = "subscription_create"; public const string SubscriptionCycle = "subscription_cycle"; + public const string SubscriptionUpdate = "subscription_update"; } public static class CollectionMethod diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index ec3b6d17988f..fcfe5319b9fc 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -19,6 +19,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using NSubstitute; +using NSubstitute.ExceptionExtensions; using NSubstitute.ReturnsExtensions; using Stripe; using Xunit; @@ -156,6 +157,8 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync( options.ProrationBehavior == ProrationBehavior.None && options.CancellationDetails != null && options.CancellationDetails.Comment != null)); + await _organizationRepository.DidNotReceive() + .ReplaceAsync(Arg.Any()); } [Fact] @@ -430,8 +433,11 @@ public async Task HandleAsync_IncompleteToIncompleteExpiredUserSubscription_Disa } }; + var user = new User { Id = userId }; + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _userRepository.GetByIdAsync(userId).Returns(user); // Act await _sut.HandleAsync(parsedEvent); @@ -518,12 +524,11 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync( } [Fact] - public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_StillSetsCancellation() + public async Task HandleAsync_WhenProviderNotFound_SkipsHandler() { // Arrange var providerId = Guid.NewGuid(); var subscriptionId = "sub_123"; - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); var previousSubscription = new Subscription { @@ -539,7 +544,7 @@ public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_St { Data = [ - new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } ] }, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, @@ -564,16 +569,10 @@ public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_St // Act await _sut.HandleAsync(parsedEvent); - // Assert - Provider not updated (since not found), but cancellation is still set + // Assert — guard exits early, no side effects await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - 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)); + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] @@ -690,6 +689,8 @@ public async Task HandleAsync_IncompleteExpiredUserSubscription_OnlyUpdatesExpir _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) .Returns(Tuple.Create(null, userId, null)); + _userRepository.GetByIdAsync(userId).Returns(new User { Id = userId }); + // Act await _sut.HandleAsync(parsedEvent); @@ -877,6 +878,9 @@ public async Task HandleAsync_SponsoredSubscription_RenewsSponsorship() _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually }); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(new FamiliesPlan()); + _stripeEventUtilityService.IsSponsoredSubscription(subscription) .Returns(true); @@ -1111,7 +1115,7 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerRepository.Received(1).GetByIdAsync(providerId); await _providerService .DidNotReceive() .UpdateAsync(Arg.Any()); @@ -1143,7 +1147,7 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerRepository.Received(1).GetByIdAsync(providerId); await _providerService .DidNotReceive() .UpdateAsync(Arg.Any()); @@ -1175,7 +1179,7 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerRepository.Received(1).GetByIdAsync(providerId); await _providerService .DidNotReceive() .UpdateAsync(Arg.Any()); @@ -1207,7 +1211,7 @@ public async Task await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerRepository.Received(1).GetByIdAsync(providerId); await _providerService .DidNotReceive() .UpdateAsync(Arg.Any()); @@ -1271,7 +1275,7 @@ public async Task HandleAsync_ActiveProviderSubscriptionEvent_WithNonMatchingPre await _stripeEventService .Received(1) .GetSubscription(parsedEvent, true, Arg.Any>()); - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _providerRepository.Received(1).GetByIdAsync(providerId); await _providerService .DidNotReceive() .UpdateAsync(Arg.Any()); @@ -1354,6 +1358,8 @@ public async Task HandleAsync_IncompleteUserSubscription_OnlyUpdatesExpiration() _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _userRepository.GetByIdAsync(userId).Returns(new User { Id = userId }); + // Act await _sut.HandleAsync(parsedEvent); @@ -1575,6 +1581,9 @@ public async Task HandleAsync_ScheduleTriggeredMigration_FlagOff_DoesNotUpdateOr .Returns(subscription); _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) .Returns(false); + var organization = new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(new FamiliesPlan()); // Act await _sut.HandleAsync(parsedEvent); @@ -1623,6 +1632,9 @@ public async Task HandleAsync_NoSchedule_FlagOn_DoesNotUpdateOrganization() .Returns(subscription); _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) .Returns(true); + var organization = new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(new FamiliesPlan()); // Act await _sut.HandleAsync(parsedEvent); @@ -1685,6 +1697,7 @@ public async Task HandleAsync_ScheduleTriggered_PreviousPriceNotOldFamilies_Does _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); + _organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually }); // Act await _sut.HandleAsync(parsedEvent); @@ -1742,6 +1755,7 @@ public async Task HandleAsync_ScheduleTriggered_CurrentPriceNotNewFamilies_DoesN _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) .Returns(true); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually }); // Act await _sut.HandleAsync(parsedEvent); @@ -1784,6 +1798,7 @@ public async Task HandleAsync_ScheduleTriggered_NoItemChanges_DoesNotUpdateOrgan .Returns(subscription); _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) .Returns(true); + _organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId }); // Act await _sut.HandleAsync(parsedEvent); @@ -1793,7 +1808,7 @@ public async Task HandleAsync_ScheduleTriggered_NoItemChanges_DoesNotUpdateOrgan } [Fact] - public async Task HandleAsync_ScheduleTriggered_OrganizationNotFound_DoesNotThrow() + public async Task HandleAsync_ScheduleTriggeredMigration_WhenOrganizationNotFound_SkipsHandler() { // Arrange var organizationId = Guid.NewGuid(); @@ -1850,7 +1865,7 @@ public async Task HandleAsync_ScheduleTriggered_OrganizationNotFound_DoesNotThro // Act await _sut.HandleAsync(parsedEvent); - // Assert — logs warning, does not throw, does not update + // Assert — guard exits early — logs warning, does not throw, does not update await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); } @@ -1934,4 +1949,380 @@ public async Task HandleAsync_ScheduleTriggered_MultipleItems_MatchesFamiliesPri await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(o => o.Id == organizationId)); } + + [Fact] + public async Task HandleAsync_UnpaidOrganizationSubscription_WithExemptOrganization_DoesNotDisableAndClearsExemption() + { + // 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 = SubscriptionStatus.Unpaid, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + } + ] + }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } + }; + + var organization = new Organization + { + Id = organizationId, + Enabled = true, + ExemptFromBillingAutomation = true, + 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); + + _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); + + // Assert + await _organizationDisableCommand.DidNotReceive() + .DisableAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive() + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(o => o.Id == organizationId && !o.ExemptFromBillingAutomation)); + } + + [Fact] + public async Task HandleAsync_UnpaidOrganizationSubscription_WithSubscriptionUpdateBillingReason_DoesNotDisableAndPreservesExemption() + { + // 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 = SubscriptionStatus.Unpaid, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + } + ] + }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionUpdate } + }; + + var organization = new Organization + { + Id = organizationId, + Enabled = true, + ExemptFromBillingAutomation = true, + 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); + + _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); + + // Assert — subscription_update billing reason does not match SubscriptionWentUnpaid + // (which filters on subscription_create and subscription_cycle only), so no disable, + // no cancellation, and the exempt flag is left unchanged. + await _organizationDisableCommand.DidNotReceive() + .DisableAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive() + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + await _organizationRepository.DidNotReceive() + .ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_UnpaidOrganizationSubscription_WithAutomaticPendingInvoiceItemInvoiceBillingReason_DoesNotDisableAndPreservesExemption() + { + // 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 = SubscriptionStatus.Unpaid, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + } + ] + }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.AutomaticPendingInvoiceItemInvoice } + }; + + var organization = new Organization + { + Id = organizationId, + Enabled = true, + ExemptFromBillingAutomation = true, + 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); + + _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); + + // Assert — automatic_pending_invoice_item_invoice does not match SubscriptionWentUnpaid + // (which filters on subscription_create and subscription_cycle only), so no disable, + // no cancellation, and the exempt flag is left unchanged. + await _organizationDisableCommand.DidNotReceive() + .DisableAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive() + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + await _organizationRepository.DidNotReceive() + .ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_UnpaidOrganizationSubscription_WithExemptOrganization_WhenSubsequentWorkFails_DoesNotClearExemption() + { + // 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 = SubscriptionStatus.Unpaid, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + } + ] + }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } + }; + + var organization = new Organization + { + Id = organizationId, + Enabled = true, + ExemptFromBillingAutomation = true, + 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); + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _organizationService.UpdateExpirationDateAsync(organizationId, Arg.Any()) + .ThrowsAsync(new Exception("Simulated failure in subsequent work")); + + // Act + await Assert.ThrowsAsync(() => _sut.HandleAsync(parsedEvent)); + + // Assert — the flag clear must not have been persisted + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenUserNotFound_SkipsHandler() + { + // Arrange + var userId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Active + }; + + var subscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Unpaid, + Metadata = new Dictionary { { "userId", userId.ToString() } }, + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }] + }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } + }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + _userRepository.GetByIdAsync(userId).ReturnsNull(); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — guard exits early, no side effects + await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any(), Arg.Any()); + await _userService.DidNotReceive().UpdatePremiumExpirationAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenOrganizationNotFound_SkipsHandler() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Active + }; + + var subscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Unpaid, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }] + }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } + }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + _organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — guard exits early, no side effects + await _organizationDisableCommand.DidNotReceive().DisableAsync(Arg.Any(), Arg.Any()); + await _organizationService.DidNotReceive().UpdateExpirationDateAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + } }