diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index ec4cb9d63d03..56bef5af8375 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -83,7 +83,7 @@ public SubscriptionUpdatedHandler( public async Task HandleAsync(Event parsedEvent) { - var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]); + var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer.discount", "discounts", "latest_invoice", "test_clock"]); SubscriberId subscriberId = subscription; var subscriber = await GetSubscriberAsync(subscriberId); @@ -338,8 +338,6 @@ private async Task SetSubscriptionToCancelAsync(Subscription subscription) private async Task RemovePendingCancellationAsync(Subscription subscription) { - await _priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); - await _stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { CancelAtPeriodEnd = false, @@ -352,6 +350,7 @@ private async Task RemovePendingCancellationAsync(Subscription subscription) [MetadataKeys.CancellationOrigin] = string.Empty } }); + await _priceIncreaseScheduler.ScheduleForSubscription(subscription); } /// diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 8bc5a07ce366..c985fb16b1d8 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -22,6 +22,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Stripe; +using Stripe.TestHelpers; using Event = Stripe.Event; using Plan = Bit.Core.Models.StaticStore.Plan; using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; @@ -356,48 +357,29 @@ private async Task ScheduleBusinessPlanPriceMigrationAsync( } var assignment = await assignmentRepository.GetByOrganizationIdAsync(organization.Id); - if (assignment is null || assignment.ScheduledDate is not null) { return false; } - var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId); - - if (cohort is null || !cohort.IsActive) - { - return false; - } - - if (cohort.MigrationPathId is null) + if (subscription.TestClock != null) { - // Churn-only cohort — no migration to schedule. - return false; - } - - var migrationPath = MigrationPaths.FromId(cohort.MigrationPathId.Value); - if (migrationPath is null) - { - logger.LogError( - "Unknown MigrationPathId ({MigrationPathId}) on cohort ({CohortId}) for Organization ({OrganizationId})", - cohort.MigrationPathId, cohort.Id, organization.Id); - return false; + await WaitForTestClockToAdvanceAsync(subscription.TestClock); } - if (organization.PlanType != migrationPath.FromPlan) - { - logger.LogWarning( - "Skipping business price migration for Organization ({OrganizationId}); PlanType {ActualPlan} does not match cohort {CohortName} source {ExpectedPlan}", - organization.Id, organization.PlanType, cohort.Name, migrationPath.FromPlan); - return false; - } + var scheduled = await priceIncreaseScheduler.ScheduleForSubscription(subscription); - var scheduled = await priceIncreaseScheduler.ScheduleBusinessPriceIncrease(subscription, cohort); if (!scheduled) { return true; } + var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId); + if (cohort?.MigrationPathId is null) return true; + + var migrationPath = MigrationPaths.FromId(cohort.MigrationPathId.Value); + if (migrationPath is null) return true; + var sourcePlan = await pricingClient.GetPlanOrThrow(migrationPath.FromPlan); var targetPlan = await pricingClient.GetPlanOrThrow(migrationPath.ToPlan); @@ -428,6 +410,19 @@ private Task SendBusinessRenewalEmailAsync( return Task.CompletedTask; } + private async Task WaitForTestClockToAdvanceAsync(TestClock testClock) + { + while (testClock.Status != "ready") + { + await Task.Delay(TimeSpan.FromSeconds(2)); + testClock = await stripeAdapter.GetTestClockAsync(testClock.Id); + if (testClock.Status == "internal_failure") + { + throw new Exception("Stripe Test Clock encountered an internal failure"); + } + } + } + #endregion #region Premium Users diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index a75251f3fe30..8ee64c03e213 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -99,6 +99,7 @@ public static class MetadataKeys public const string CancelledDuringDeferredPriceIncrease = "cancelled_during_deferred_price_increase"; public const string MigrationCohortId = "migration_cohort_id"; public const string MigrationCohortName = "migration_cohort_name"; + public const string CancellingUserId = "cancellingUserId"; } public static class CancellationOrigins diff --git a/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs b/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs new file mode 100644 index 000000000000..0567a66697f4 --- /dev/null +++ b/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs @@ -0,0 +1,15 @@ +namespace Bit.Core.Billing.Pricing; + +/// +/// Controls optional guard behavior when scheduling an organization price increase via +/// . Guards are applied +/// during cohort validation before any Stripe calls are made. +/// +public record OrganizationPriceIncreaseOptions +{ + /// + /// Skip scheduling if a price increase has already been scheduled for this + /// organization (i.e. assignment.ScheduledDate is set). + /// + public bool SkipIfAlreadyScheduled { get; init; } +} diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 567e28c62cb0..3bcb8de7e998 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.PlanMigration; using Bit.Core.Billing.Organizations.PlanMigration.Entities; @@ -6,6 +7,7 @@ using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; using Stripe; @@ -45,6 +47,19 @@ public interface IPriceIncreaseScheduler /// True if a new schedule was created; false if skipped. Task ScheduleBusinessPriceIncrease(Subscription subscription, OrganizationPlanMigrationCohort cohort); + /// + /// Creates a deferred price-increase schedule for the given subscription, + /// dispatching to the correct path based on the subscription owner. + /// + /// The Stripe subscription to schedule a price increase for. + /// + /// Optional guards applied before scheduling. + /// + /// True if a new schedule was created; false if skipped. + Task ScheduleForSubscription( + Subscription subscription, + OrganizationPriceIncreaseOptions? options = null); + /// /// Releases any active subscription schedule for the given subscription, cancelling a pending /// deferred price increase. Use when the subscription operation makes the scheduled migration @@ -61,7 +76,9 @@ public class PriceIncreaseScheduler( IStripeAdapter stripeAdapter, IFeatureService featureService, IPricingClient pricingClient, + IOrganizationRepository organizationRepository, IOrganizationPlanMigrationCohortAssignmentRepository assignmentRepository, + IOrganizationPlanMigrationCohortRepository cohortRepository, ILogger logger) : IPriceIncreaseScheduler { public async Task SchedulePersonalPriceIncrease(Subscription subscription) @@ -169,6 +186,33 @@ public async Task ScheduleBusinessPriceIncrease( return true; } + public async Task ScheduleForSubscription( + Subscription subscription, + OrganizationPriceIncreaseOptions? options = null) + { + try + { + SubscriberId subscriberId = subscription; + return await subscriberId.Match( + _ => SchedulePersonalPriceIncrease(subscription), + orgId => ScheduleForOrganizationAsync(subscription, orgId.Value, options), + _ => + { + logger.LogWarning( + "Provider subscriptions do not support schedule recovery ({SubscriptionId})", + subscription.Id); + return Task.FromResult(false); + }); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to resolve subscriber type for subscription ({SubscriptionId}), cannot recover schedule", + subscription.Id); + return false; + } + } + public async Task Release(string customerId, string subscriptionId) { try @@ -547,4 +591,101 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, }; } + /// + /// Coordinates the full organization scheduling flow. Resolves the organization, routes + /// non-business plan types (personal, family, and 2019-era plans) to the personal scheduling + /// path, then validates cohort eligibility before scheduling. + /// + private async Task ScheduleForOrganizationAsync( + Subscription subscription, + Guid organizationId, + OrganizationPriceIncreaseOptions? options) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + if (organization is null) + { + logger.LogError( + "Organization ({OrganizationId}) not found; cannot recover schedule for subscription ({SubscriptionId})", + organizationId, subscription.Id); + return false; + } + + if (!IsTrackABusinessPlanType(organization.PlanType)) + { + return await SchedulePersonalPriceIncrease(subscription); + } + + var assignment = await assignmentRepository.GetByOrganizationIdAsync(organization.Id); + if (assignment is null) + { + return false; + } + + var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId); + if (cohort is null) + { + return false; + } + + if (!IsEligibleForScheduling(organization, assignment, cohort, options)) + { + return false; + } + + return await ScheduleBusinessPriceIncrease(subscription, cohort); + } + + /// + /// Returns true if the organization is eligible for a business plan price increase. + /// Guards from are evaluated first against all resolved data; + /// structural eligibility checks follow. Add new option guards here — + /// never needs to change. + /// Does not make any database or Stripe calls. + /// + private bool IsEligibleForScheduling( + Organization organization, + OrganizationPlanMigrationCohortAssignment assignment, + OrganizationPlanMigrationCohort cohort, + OrganizationPriceIncreaseOptions? options) + { + if (options?.SkipIfAlreadyScheduled == true && assignment.ScheduledDate is not null) + { + return false; + } + + if (!cohort.IsActive || cohort.MigrationPathId is null) + { + return false; + } + + var migrationPath = MigrationPaths.FromId(cohort.MigrationPathId.Value); + if (migrationPath is null) + { + logger.LogError( + "Unknown MigrationPathId ({MigrationPathId}) on cohort ({CohortId}); skipping schedule for organization ({OrganizationId})", + cohort.MigrationPathId, cohort.Id, organization.Id); + return false; + } + + if (organization.PlanType != migrationPath.FromPlan) + { + logger.LogWarning( + "Skipping schedule for Organization ({OrganizationId}); PlanType {ActualPlan} does not match cohort {CohortName} source {ExpectedPlan}", + organization.Id, organization.PlanType, cohort.Name, migrationPath.FromPlan); + return false; + } + + return true; + } + + /// + /// Returns true if the plan type is a Track A business plan type. + /// + /// Expand to include additional business plan types as new tracks are added. + private static bool IsTrackABusinessPlanType(PlanType planType) => planType is + PlanType.TeamsMonthly2020 or + PlanType.TeamsAnnually2020 or + PlanType.EnterpriseMonthly2020 or + PlanType.EnterpriseAnnually2020; + } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index c0c90988c3e5..2cd268e6c13b 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -67,7 +67,7 @@ public async Task CancelSubscription( ]; // Build once from survey — null when survey is absent (system-initiated cancellation) - var cancellationDetails = offboardingSurveyResponse != null + var cancellationDetails = offboardingSurveyResponse is not null ? new SubscriptionCancellationDetailsOptions { Comment = offboardingSurveyResponse.Feedback, @@ -77,21 +77,25 @@ public async Task CancelSubscription( } : null; - var cancellingUserMetadata = offboardingSurveyResponse != null + var cancellingUserMetadata = offboardingSurveyResponse is not null ? new Dictionary { - { "cancellingUserId", offboardingSurveyResponse.UserId.ToString() } + { MetadataKeys.CancellingUserId, offboardingSurveyResponse.UserId.ToString() } } : null; - if (cancelImmediately) + if (featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { - await CancelSubscriptionImmediatelyAsync(subscription, cancellationDetails, cancellingUserMetadata); - } - else - { - await CancelSubscriptionAtPeriodEndAsync(subscription, cancellationDetails, cancellingUserMetadata); + cancellingUserMetadata = new Dictionary(cancellingUserMetadata ?? []) + { + [MetadataKeys.MigrationCohortId] = string.Empty, + [MetadataKeys.MigrationCohortName] = string.Empty, + }; } + + await (cancelImmediately + ? CancelSubscriptionImmediatelyAsync(subscription, cancellationDetails, cancellingUserMetadata) + : CancelSubscriptionAtPeriodEndAsync(subscription, cancellationDetails, cancellingUserMetadata)); } public async Task CreateBraintreeCustomer( @@ -233,7 +237,8 @@ private async Task CancelSubscriptionImmediatelyAsync( SubscriptionCancellationDetailsOptions? cancellationDetails, Dictionary? cancellingUserMetadata) { - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) || + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { var activeSchedule = await GetActiveScheduleAsync(subscription); if (activeSchedule != null) @@ -268,7 +273,8 @@ private async Task CancelSubscriptionAtPeriodEndAsync( Metadata = cancellingUserMetadata }; - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) || + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { var activeSchedule = await GetActiveScheduleAsync(subscription); @@ -557,7 +563,8 @@ await braintreeGateway.Customer.UpdateAsync( public async Task ResumeFromUnpaidCancellationAsync(ISubscriber subscriber) { - var subscription = await GetSubscription(subscriber); + var subscription = await GetSubscription(subscriber, + new SubscriptionGetOptions { Expand = ["customer.discount", "discounts"] }); if (subscription is null || subscription.Status != SubscriptionStatus.Unpaid || @@ -578,6 +585,8 @@ subscription.Metadata is null || } }); + await priceIncreaseScheduler.ScheduleForSubscription(subscription); + logger.LogInformation( "Cleared pending unpaid-lifecycle cancellation for subscription ({SubscriptionId}) after subscriber re-enable", subscription.Id); @@ -602,6 +611,8 @@ public async Task ScheduleUnpaidCancellationAsync(ISubscriber subscriber) var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + await priceIncreaseScheduler.Release(subscription.CustomerId, subscription.Id); + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { CancelAt = now.AddDays(7), diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs index 6f0ea2789687..5910d2bf7113 100644 --- a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -30,7 +30,7 @@ public Task> Run(ISubscriber subscriber) => HandleAsy { var subscription = await stripeAdapter.GetSubscriptionAsync( subscriber.GatewaySubscriptionId, - new SubscriptionGetOptions { Expand = ["discounts"] }); + new SubscriptionGetOptions { Expand = ["discounts", "customer.discount"] }); if (subscription is not { @@ -41,7 +41,8 @@ public Task> Run(ISubscriber subscriber) => HandleAsy return new BadRequest("Subscription is not pending cancellation."); } - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) || + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { if (subscription.Metadata?.ContainsKey(MetadataKeys.CancelledDuringDeferredPriceIncrease) == true) { @@ -49,18 +50,19 @@ public Task> Run(ISubscriber subscriber) => HandleAsy "{Command}: Subscription ({SubscriptionId}) has pending price increase, clearing flag and recreating schedule", CommandName, subscription.Id); - // Clear pending cancellation and flag BEFORE attaching a schedule. + // Clear pending cancellation, cancelling user, and flag BEFORE attaching a schedule. // Stripe discourages direct subscription updates once a schedule is attached as it can create inconsistencies in phases. await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { CancelAtPeriodEnd = false, Metadata = new Dictionary { - [MetadataKeys.CancelledDuringDeferredPriceIncrease] = "" + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = string.Empty, + [MetadataKeys.CancellingUserId] = string.Empty } }); - await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); + await priceIncreaseScheduler.ScheduleForSubscription(subscription); return new None(); } @@ -70,7 +72,11 @@ public Task> Run(ISubscriber subscriber) => HandleAsy // active schedules is to simply not cancel at the end of the period. await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { - CancelAtPeriodEnd = false + CancelAtPeriodEnd = false, + Metadata = new Dictionary + { + [MetadataKeys.CancellingUserId] = string.Empty + } }); return new None(); diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index f65fc5c091bf..d70f9772d2f7 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -1401,7 +1401,7 @@ public async Task HandleAsync_UnpaidSubscription_ReleasesScheduleBeforeCancellat } [Fact] - public async Task HandleAsync_ActiveSubscription_SchedulesBeforeRemovingCancellation() + public async Task HandleAsync_ActiveSubscription_RemovesCancellationAndAddsSchedules() { // Arrange var organizationId = Guid.NewGuid(); @@ -1436,7 +1436,7 @@ public async Task HandleAsync_ActiveSubscription_SchedulesBeforeRemovingCancella await _sut.HandleAsync(parsedEvent); // Assert - await _priceIncreaseScheduler.Received(1).SchedulePersonalPriceIncrease(subscription); + await _priceIncreaseScheduler.Received(1).ScheduleForSubscription(subscription); await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, Arg.Is(o => o.CancelAtPeriodEnd == false)); } diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 09dc9fa09ddb..f085c04d7a67 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -3525,15 +3525,15 @@ public async Task HandleAsync_WhenBusinessTier_AndCohortInactive_FallsThroughToS // Act await _sut.HandleAsync(parsedEvent); - // Assert - await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs() - .ScheduleBusinessPriceIncrease(default!, default!); - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), + // Assert — cohort validation now handled internally by scheduler; handler falls through silently + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any>(), - Arg.Is(b => b)); + Arg.Any()); } [Fact] @@ -3579,7 +3579,7 @@ public async Task HandleAsync_WhenBusinessTier_AndCohortHasNoMigrationPath_Falls // Act await _sut.HandleAsync(parsedEvent); - // Assert — silent fall-through: no migration logs at any severity. + // Assert — churn-only cohort validation handled internally by scheduler; no migration logs at handler level. _logger.DidNotReceive().Log( LogLevel.Warning, Arg.Any(), @@ -3592,14 +3592,14 @@ public async Task HandleAsync_WhenBusinessTier_AndCohortHasNoMigrationPath_Falls Arg.Is(o => o.ToString()!.Contains("MigrationPathId")), Arg.Any(), Arg.Any>()); - await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs() - .ScheduleBusinessPriceIncrease(default!, default!); - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any>(), - Arg.Is(b => b)); + Arg.Any()); } [Fact] @@ -3645,23 +3645,15 @@ public async Task HandleAsync_WhenBusinessTier_AndUnknownMigrationPathId_LogsErr // Act await _sut.HandleAsync(parsedEvent); - // Assert - _logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => - o.ToString()!.Contains("Unknown MigrationPathId") && - o.ToString()!.Contains(_organizationId.ToString())), - Arg.Any(), - Arg.Any>()); - await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs() - .ScheduleBusinessPriceIncrease(default!, default!); - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), + // Assert — unknown migration path is logged and handled internally by scheduler; handler falls through silently + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any>(), - Arg.Is(b => b)); + Arg.Any()); } [Fact] @@ -3707,23 +3699,15 @@ public async Task HandleAsync_WhenBusinessTier_AndOrgPlanDriftedFromCohortSource // Act await _sut.HandleAsync(parsedEvent); - // Assert - _logger.Received(1).Log( - LogLevel.Warning, - Arg.Any(), - Arg.Is(o => - o.ToString()!.Contains("Skipping business price migration") && - o.ToString()!.Contains(_organizationId.ToString())), - Arg.Any(), - Arg.Any>()); - await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs() - .ScheduleBusinessPriceIncrease(default!, default!); - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), + // Assert — plan drift logged and handled internally by scheduler; handler falls through silently + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any>(), - Arg.Is(b => b)); + Arg.Any()); } [Fact] @@ -3767,13 +3751,15 @@ public async Task HandleAsync_WhenBusinessTier_AndSchedulerReturnsTrue_InvokesPl _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); _assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment); _cohortRepository.GetByIdAsync(cohortId).Returns(cohort); - _priceIncreaseScheduler.ScheduleBusinessPriceIncrease(subscription, cohort).Returns(true); + _priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any()) + .Returns(true); // Act await _sut.HandleAsync(parsedEvent); // Assert - await _priceIncreaseScheduler.Received(1).ScheduleBusinessPriceIncrease(subscription, cohort); + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); _logger.Received(1).Log( LogLevel.Information, Arg.Any(), @@ -3893,7 +3879,7 @@ public async Task HandleAsync_WhenBusinessTier_AndSchedulerThrows_LogsErrorAndFa _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); _assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment); _cohortRepository.GetByIdAsync(cohortId).Returns(cohort); - _priceIncreaseScheduler.ScheduleBusinessPriceIncrease(subscription, cohort) + _priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any()) .ThrowsAsync(new Exception("boom")); // Act diff --git a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs index 1692aca69020..c568c579f870 100644 --- a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs +++ b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs @@ -1,9 +1,11 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Entities; using Bit.Core.Billing.Organizations.PlanMigration.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; using Microsoft.Extensions.Logging; @@ -22,12 +24,15 @@ public class PriceIncreaseSchedulerTests private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly IFeatureService _featureService = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); private readonly IOrganizationPlanMigrationCohortAssignmentRepository _assignmentRepository = Substitute.For(); + private readonly IOrganizationPlanMigrationCohortRepository _cohortRepository = + Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private PriceIncreaseScheduler CreateSut() => - new(_stripeAdapter, _featureService, _pricingClient, _assignmentRepository, _logger); + new(_stripeAdapter, _featureService, _pricingClient, _organizationRepository, _assignmentRepository, _cohortRepository, _logger); [Fact] public async Task SchedulePersonalPriceIncrease_FeatureFlagOff_DoesNothing() @@ -1597,6 +1602,310 @@ await _assignmentRepository.DidNotReceiveWithAnyArgs() .GetByOrganizationIdAsync(Arg.Any()); } + [Fact] + public async Task ScheduleForSubscription_UserSubscription_RoutesToPersonalPath_CreatesSchedule() + { + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + + var oldPremium = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + Seat = new Purchasable { StripePriceId = "premium-old-seat", Price = 10, Provided = 1 }, + Storage = new Purchasable { StripePriceId = "premium-old-storage", Price = 4, Provided = 1 } + }; + + var newPremium = new PremiumPlan + { + Name = "Premium", + Available = true, + Seat = new Purchasable { StripePriceId = "premium-new-seat", Price = 15, Provided = 1 }, + Storage = new Purchasable { StripePriceId = "premium-new-storage", Price = 4, Provided = 1 } + }; + + _pricingClient.ListPremiumPlans().Returns([oldPremium, newPremium]); + + var subscription = CreateSubscription("sub_1", "cus_1", + CreateSubscriptionItem("premium-old-seat", 1)); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.True(result); + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[1].Items.Any(i => i.Price == "premium-new-seat"))); + } + + [Fact] + public async Task ScheduleForSubscription_TrackAOrg_ActiveCohortMatchingPlan_RoutesToBusinessPath_CreatesSchedule() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(target); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.PasswordManager.StripeSeatPlanId, 10)); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.True(result); + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[1].Items.Any(i => i.Price == target.PasswordManager.StripeSeatPlanId))); + } + + [Fact] + public async Task ScheduleForSubscription_TrackAOrg_NoAssignment_ReturnsFalse() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var orgId = Guid.NewGuid(); + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns((OrganizationPlanMigrationCohortAssignment?)null); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_TrackAOrg_InactiveCohort_ReturnsFalse() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var orgId = Guid.NewGuid(); + var cohortId = Guid.NewGuid(); + var inactiveCohort = new OrganizationPlanMigrationCohort + { + Id = cohortId, + Name = "inactive", + MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent, + IsActive = false + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId) + .Returns(new OrganizationPlanMigrationCohortAssignment { OrganizationId = orgId, CohortId = cohortId }); + _cohortRepository.GetByIdAsync(cohortId).Returns(inactiveCohort); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_TrackAOrg_PlanTypeDrifted_ReturnsFalse() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + + // Org PlanType is EnterpriseAnnually (already migrated), not EnterpriseAnnually2020 + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually)); + _assignmentRepository.GetByOrganizationIdAsync(orgId) + .Returns(new OrganizationPlanMigrationCohortAssignment { OrganizationId = orgId, CohortId = cohort.Id }); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_NonTrackAOrg_FamiliesOrg_RoutesToPersonalPath() + { + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + + var orgId = Guid.NewGuid(); + var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); + var familiesTarget = MockPlans.Get(PlanType.FamiliesAnnually); + var families2025 = MockPlans.Get(PlanType.FamiliesAnnually2025); + + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesTarget); + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.FamiliesAnnually2019)); + + var subscription = CreateSubscription("sub_1", "cus_1", + new Dictionary { { "organizationId", orgId.ToString() } }, + CreateSubscriptionItem(families2019.PasswordManager.StripePlanId, 1)); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.True(result); + await _stripeAdapter.Received(1).CreateSubscriptionScheduleAsync(Arg.Any()); + await _cohortRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await _assignmentRepository.DidNotReceiveWithAnyArgs().GetByOrganizationIdAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_ProviderSubscription_ReturnsFalse() + { + var providerId = Guid.NewGuid(); + var subscription = CreateSubscription("sub_1", "cus_1", + new Dictionary { { "providerId", providerId.ToString() } }); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_SkipIfAlreadyScheduled_ScheduledDateSet_ReturnsFalse() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id, + ScheduledDate = DateTime.UtcNow + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription( + subscription, + new OrganizationPriceIncreaseOptions { SkipIfAlreadyScheduled = true }); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_DefaultOptions_ScheduledDateSet_ProceedsToSchedule() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(target); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id, + ScheduledDate = DateTime.UtcNow // already scheduled, but no SkipIfAlreadyScheduled guard + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.PasswordManager.StripeSeatPlanId, 10)); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); // default options + + Assert.True(result); + await _stripeAdapter.Received(1) + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + private static Subscription CreateSubscription(string id, string customerId, params SubscriptionItem[] items) => CreateSubscription(id, customerId, new Dictionary { { "userId", Guid.NewGuid().ToString() } }, items); @@ -1677,4 +1986,9 @@ private static OrganizationPlanMigrationCohort CreateCohort( ProactiveDiscountCouponCode = proactiveCoupon, IsActive = true }; + + private static Organization CreateOrganization(Guid id, PlanType planType) => + new() { Id = id, PlanType = planType }; + + } diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index b04a7dec4988..762db13dc3de 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -93,7 +93,7 @@ public async Task CancelSubscription_CancelImmediately_BelongsToOrganization_Upd await stripeAdapter .Received(1) .UpdateSubscriptionAsync(subscriptionId, Arg.Is( - options => options.Metadata["cancellingUserId"] == userId.ToString())); + options => options.Metadata[StripeConstants.MetadataKeys.CancellingUserId] == userId.ToString())); await stripeAdapter .Received(1) @@ -188,7 +188,7 @@ await stripeAdapter options.CancelAtPeriodEnd == true && options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason && - options.Metadata["cancellingUserId"] == userId.ToString())); + options.Metadata[StripeConstants.MetadataKeys.CancellingUserId] == userId.ToString())); await stripeAdapter .DidNotReceiveWithAnyArgs() @@ -304,7 +304,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task CancelSubscription_CancelImmediately_FlagOff_DoesNotCheckOrReleaseSchedule( + public async Task CancelSubscription_CancelImmediately_BothFlagsOff_DoesNotCheckOrReleaseSchedule( Organization organization, SutProvider sutProvider) { @@ -322,6 +322,7 @@ public async Task CancelSubscription_CancelImmediately_FlagOff_DoesNotCheckOrRel var featureService = sutProvider.GetDependency(); featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); @@ -332,6 +333,92 @@ await sutProvider.GetDependency() await stripeAdapter.Received(1).CancelSubscriptionAsync(subscriptionId, Arg.Any()); } + [Theory, BitAutoData] + public async Task CancelSubscription_CancelImmediately_PM35215FlagOn_WithActiveSchedule_ReleasesSchedule( + Organization organization, + SutProvider sutProvider) + { + const string subscriptionId = "sub_1"; + const string scheduleId = "sched_1"; + + var subscription = new Subscription + { + Id = subscriptionId, + Status = "active", + CustomerId = "cus_1", + Metadata = new Dictionary() + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); + stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList + { + Data = + [ + new SubscriptionSchedule + { + Id = scheduleId, + SubscriptionId = subscriptionId, + Status = StripeConstants.SubscriptionScheduleStatus.Active + } + ] + }); + + var featureService = sutProvider.GetDependency(); + featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); + + await sutProvider.GetDependency() + .Received(1).Release("cus_1", subscriptionId); + await stripeAdapter.Received(1).CancelSubscriptionAsync(subscriptionId, Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_PM35215FlagOn_ClearsMigrationCohortMetadataOnCancel( + Organization organization, + SutProvider sutProvider) + { + const string subscriptionId = "sub_1"; + + var subscription = new Subscription + { + Id = subscriptionId, + Status = "active", + CustomerId = "cus_1", + Metadata = new Dictionary + { + { "organizationId", organization.Id.ToString() }, + { StripeConstants.MetadataKeys.MigrationCohortId, "some-cohort-id" }, + { StripeConstants.MetadataKeys.MigrationCohortName, "A1(a)" } + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); + stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var featureService = sutProvider.GetDependency(); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = Guid.NewGuid(), + Reason = "too_expensive", + Feedback = "Too expensive" + }; + + await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false, offboardingSurveyResponse); + + await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, + Arg.Is(o => + o.Metadata[StripeConstants.MetadataKeys.MigrationCohortId] == string.Empty && + o.Metadata[StripeConstants.MetadataKeys.MigrationCohortName] == string.Empty)); + } + [Theory, BitAutoData] public async Task CancelSubscription_CancelImmediately_FlagOn_NoSchedule_ProceedsNormally( Organization organization, @@ -491,7 +578,7 @@ await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, o.CancelAtPeriodEnd == true && o.CancellationDetails.Comment == "Too pricey" && o.CancellationDetails.Feedback == "too_expensive" && - o.Metadata["cancellingUserId"] == userId.ToString() && + o.Metadata[StripeConstants.MetadataKeys.CancellingUserId] == userId.ToString() && o.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancelledDuringDeferredPriceIncrease))); await stripeAdapter.DidNotReceiveWithAnyArgs() @@ -1818,4 +1905,62 @@ await stripeAdapter.Received(1).UpdateSubscriptionAsync( options.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancellationOrigin) && options.Metadata[StripeConstants.MetadataKeys.CancellationOrigin] == StripeConstants.CancellationOrigins.UnpaidSubscription)); } + + [Theory, BitAutoData] + public async Task ScheduleUnpaidCancellationAsync_UnpaidAndUnscheduled_ReleasesScheduleBeforeUpdatingSubscription( + Organization organization, + SutProvider sutProvider) + { + var subscription = new Subscription + { + Id = organization.GatewaySubscriptionId, + CustomerId = "cus_1", + Status = StripeConstants.SubscriptionStatus.Unpaid, + Metadata = new Dictionary() + }; + + sutProvider.GetDependency() + .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + + await sutProvider.Sut.ScheduleUnpaidCancellationAsync(organization); + + Received.InOrder(() => + { + sutProvider.GetDependency() + .Release("cus_1", organization.GatewaySubscriptionId); + sutProvider.GetDependency() + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + }); + } + + [Theory, BitAutoData] + public async Task ResumeFromUnpaidCancellationAsync_UnpaidWithMatchingMetadata_SchedulesAfterClearing( + Organization organization, + SutProvider sutProvider) + { + var subscription = new Subscription + { + Id = organization.GatewaySubscriptionId, + Status = StripeConstants.SubscriptionStatus.Unpaid, + Metadata = new Dictionary + { + { StripeConstants.MetadataKeys.CancellationOrigin, StripeConstants.CancellationOrigins.UnpaidSubscription } + } + }; + + sutProvider.GetDependency() + .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + + await sutProvider.Sut.ResumeFromUnpaidCancellationAsync(organization); + + Received.InOrder(() => + { + sutProvider.GetDependency() + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .ScheduleForSubscription(subscription, Arg.Any()); + }); + } } diff --git a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs index 79622de95e0a..5cf5d7066420 100644 --- a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Pricing; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Entities; @@ -39,7 +40,7 @@ public async Task Run_SubscriptionNotPendingCancellation_ReturnsBadRequest() } [Fact] - public async Task Run_FlagOff_FallsThroughToStandardReinstate_NoScheduleCheck() + public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOff_FallsThroughToStandardReinstate_NoScheduleCheck() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -55,7 +56,7 @@ public async Task Run_FlagOff_FallsThroughToStandardReinstate_NoScheduleCheck() var result = await _command.Run(user); - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.DidNotReceiveWithAnyArgs() .ListSubscriptionSchedulesAsync(Arg.Any()); await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", @@ -63,7 +64,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", } [Fact] - public async Task Run_FlagOn_NoSchedule_FallsThroughToStandardReinstate() + public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOn_NoSchedule_FallsThroughToStandardReinstate() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -82,7 +83,7 @@ public async Task Run_FlagOn_NoSchedule_FallsThroughToStandardReinstate() var result = await _command.Run(user); - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.DidNotReceiveWithAnyArgs() .UpdateSubscriptionScheduleAsync(Arg.Any(), Arg.Any()); await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", @@ -90,7 +91,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", } [Fact] - public async Task Run_FlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() + public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -113,16 +114,50 @@ public async Task Run_FlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_Rec var result = await _command.Run(user); - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", Arg.Is(o => o.CancelAtPeriodEnd == false && o.Metadata[MetadataKeys.CancelledDuringDeferredPriceIncrease] == "")); - await _priceIncreaseScheduler.Received(1).SchedulePersonalPriceIncrease(Arg.Any()); + await _priceIncreaseScheduler.Received(1).ScheduleForSubscription(Arg.Any()); } [Fact] - public async Task Run_FetchesSubscriptionWithDiscountsExpanded() + public async Task Run_BusinessPlanPriceMigrationFlagOn_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() + { + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId, GatewaySubscriptionId = "sub_1" }; + + _stripeAdapter.GetSubscriptionAsync("sub_1", Arg.Any()) + .Returns(new Subscription + { + Id = "sub_1", + Status = SubscriptionStatus.Active, + CancelAt = DateTime.UtcNow.AddDays(30), + CustomerId = "cus_1", + Metadata = new Dictionary + { + ["organizationId"] = organizationId.ToString(), + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = "true" + }, + Items = new StripeList { Data = [] } + }); + + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var result = await _command.Run(organization); + + Assert.True(result.Success); + await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", + Arg.Is(o => + o.CancelAtPeriodEnd == false && + o.Metadata[MetadataKeys.CancelledDuringDeferredPriceIncrease] == "")); + await _priceIncreaseScheduler.Received(1).ScheduleForSubscription(Arg.Any()); + } + + [Fact] + public async Task Run_FetchesSubscriptionWithRequiredExpansions() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -133,6 +168,9 @@ public async Task Run_FetchesSubscriptionWithDiscountsExpanded() await _stripeAdapter.Received(1).GetSubscriptionAsync( "sub_1", - Arg.Is(o => o.Expand != null && o.Expand.Contains("discounts"))); + Arg.Is(o => + o.Expand != null && + o.Expand.Contains("discounts") && + o.Expand.Contains("customer.discount"))); } }