From e27d25e419139529e33617f3cb2e0fd51cf5fb3a Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Mon, 29 Jun 2026 09:35:46 -0500 Subject: [PATCH 1/4] [PM-36062] refactor: Remove defer-price-migration-to-renewal feature flag Removes the PM32645_DeferPriceMigrationToRenewal flag and its now-dead immediate-migration (OFF-branch) logic, making the deferred schedule-based price migration the unconditional behavior. Collapses the three PM32645-or-PM35215 gates to PM35215 only (still in flight) and deletes the orphaned legacy cancel/reinstate methods left behind. --- .../Controllers/OrganizationsController.cs | 18 +- .../SubscriptionUpdatedHandler.cs | 5 +- .../Implementations/UpcomingInvoiceHandler.cs | 193 +- ...teClaimedOrganizationUserAccountCommand.cs | 19 +- .../OrganizationDeleteCommand.cs | 17 +- .../Services/IOrganizationService.cs | 1 - .../Implementations/OrganizationService.cs | 11 - .../Commands/UpdateBillingAddressCommand.cs | 101 +- .../Billing/Pricing/PriceIncreaseScheduler.cs | 15 +- .../Billing/Services/IStripePaymentService.cs | 2 - .../Implementations/StripePaymentService.cs | 77 - .../Implementations/SubscriberService.cs | 6 +- .../Commands/ReinstateSubscriptionCommand.cs | 3 +- src/Core/Constants.cs | 1 - src/Core/Services/IUserService.cs | 1 - .../Services/Implementations/UserService.cs | 30 +- .../OrganizationsControllerTests.cs | 28 +- .../SubscriptionUpdatedHandlerTests.cs | 69 +- .../Services/UpcomingInvoiceHandlerTests.cs | 1621 +---------------- ...imedOrganizationUserAccountCommandTests.cs | 168 +- .../OrganizationDeleteCommandTests.cs | 61 +- .../UpdateBillingAddressCommandTests.cs | 19 +- .../Pricing/PriceIncreaseSchedulerTests.cs | 65 +- .../Services/SubscriberServiceTests.cs | 32 +- .../ReinstateSubscriptionCommandTests.cs | 15 +- test/Core.Test/Services/UserServiceTests.cs | 57 +- 26 files changed, 185 insertions(+), 2450 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 1af251a30668..69e79b36c6a0 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -50,7 +50,6 @@ public class OrganizationsController( ISubscriberService subscriberService, IOrganizationInstallationRepository organizationInstallationRepository, IPricingClient pricingClient, - IFeatureService featureService, IReinstateSubscriptionCommand reinstateSubscriptionCommand) : Controller { @@ -250,20 +249,13 @@ public async Task PostReinstate(Guid id) throw new NotFoundException(); } - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - var organization = await organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - (await reinstateSubscriptionCommand.Run(organization)).GetValueOrThrow(); - } - else + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) { - await organizationService.ReinstateSubscriptionAsync(id); + throw new NotFoundException(); } + + (await reinstateSubscriptionCommand.Run(organization)).GetValueOrThrow(); } /// diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 65e989e758f7..1d0b0e6be656 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -143,10 +143,7 @@ public async Task HandleAsync(Event parsedEvent) break; case Organization organization: { - if (_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - await HandleScheduleTriggeredFamiliesMigrationAsync(parsedEvent, subscription, organization.Id); - } + await HandleScheduleTriggeredFamiliesMigrationAsync(parsedEvent, subscription, organization.Id); await HandleScheduleTriggeredBusinessMigrationAsync(parsedEvent, subscription, organization.Id); diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 34d33302c3e7..04f352907fa4 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -269,67 +269,10 @@ private async Task ScheduleFamiliesPriceMigrationAsync( try { - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); + if (!scheduled) { - var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); - if (!scheduled) - { - return true; - } - } - else - { - organization.PlanType = familiesPlan.Type; - organization.Plan = familiesPlan.Name; - organization.UsersGetPremium = familiesPlan.UsersGetPremium; - organization.Seats = familiesPlan.PasswordManager.BaseSeats; - - var options = new SubscriptionUpdateOptions - { - Items = - [ - new SubscriptionItemOptions - { - Id = passwordManagerItem.Id, - Price = familiesPlan.PasswordManager.StripePlanId - } - ], - ProrationBehavior = ProrationBehavior.None - }; - - if (plan.Type == PlanType.FamiliesAnnually2019) - { - options.Discounts = - [ - new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount } - ]; - - var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item => - item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId); - - if (premiumAccessAddOnItem != null) - { - options.Items.Add(new SubscriptionItemOptions - { - Id = premiumAccessAddOnItem.Id, - Deleted = true - }); - } - - var seatAddOnItem = subscription.Items.FirstOrDefault(item => item.Price.Id == "personal-org-seat-annually"); - - if (seatAddOnItem != null) - { - options.Items.Add(new SubscriptionItemOptions - { - Id = seatAddOnItem.Id, - Deleted = true - }); - } - } - - await organizationRepository.ReplaceAsync(organization); - await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); + return true; } await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan); @@ -752,29 +695,10 @@ private async Task AlignPremiumUsersSubscriptionConcernsAsync( try { - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); - if (!scheduled) - { - return true; - } - } - else + var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); + if (!scheduled) { - await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, - new SubscriptionUpdateOptions - { - Items = - [ - new SubscriptionItemOptions { Id = premiumItem.Id, Price = newPlan.Seat.StripePriceId } - ], - Discounts = - [ - new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } - ], - ProrationBehavior = ProrationBehavior.None - }); + return true; } await SendPremiumRenewalEmailAsync(user, newPlan); @@ -913,73 +837,70 @@ await mailService.SendProviderInvoiceUpcoming( private async Task EnableAutomaticTaxAsync(Subscription subscription) { - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync( - new SubscriptionScheduleListOptions { Customer = subscription.CustomerId }); + var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync( + new SubscriptionScheduleListOptions { Customer = subscription.CustomerId }); - var activeSchedule = schedules.Data.FirstOrDefault(s => - s.SubscriptionId == subscription.Id && s.Status == SubscriptionScheduleStatus.Active); + var activeSchedule = schedules.Data.FirstOrDefault(s => + s.SubscriptionId == subscription.Id && s.Status == SubscriptionScheduleStatus.Active); - if (activeSchedule != null) + if (activeSchedule != null) + { + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + var phases = new List(); + + for (var i = 0; i < activeSchedule.Phases.Count; i++) { - var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; - var phases = new List(); + var phase = activeSchedule.Phases[i]; - for (var i = 0; i < activeSchedule.Phases.Count; i++) + // Skip phases that have already completed + if (phase.EndDate <= now) { - var phase = activeSchedule.Phases[i]; + continue; + } - // Skip phases that have already completed - if (phase.EndDate <= now) - { - continue; - } + // When a phase's predecessor has ended, the phase is already active and + // its one-time migration discount has been applied and consumed. + // Re-including it would cause Stripe to re-apply it. + var discountConsumed = i > 0 && activeSchedule.Phases[i - 1].EndDate <= now; - // When a phase's predecessor has ended, the phase is already active and - // its one-time migration discount has been applied and consumed. - // Re-including it would cause Stripe to re-apply it. - var discountConsumed = i > 0 && activeSchedule.Phases[i - 1].EndDate <= now; + // Gate on StartDate > now, not !discountConsumed (false for the active phase 0), + // so we never re-stack the customer coupon onto the already-billing current period. + var customerDiscount = phase.StartDate > now ? subscription.Customer?.Discount : null; - // Gate on StartDate > now, not !discountConsumed (false for the active phase 0), - // so we never re-stack the customer coupon onto the already-billing current period. - var customerDiscount = phase.StartDate > now ? subscription.Customer?.Discount : null; + phases.Add(new SubscriptionSchedulePhaseOptions + { + StartDate = phase.StartDate, + EndDate = phase.EndDate, + Items = phase.Items.Select(item => new SubscriptionSchedulePhaseItemOptions + { + Price = item.PriceId, + Quantity = item.Quantity + }).ToList(), + Discounts = discountConsumed + ? [] + : customerDiscount.MergeDiscountCouponIds( + phase.Discounts?.Select(d => d.CouponId)).ToPhaseDiscountOptions(), + ProrationBehavior = phase.ProrationBehavior, + AutomaticTax = new SubscriptionSchedulePhaseAutomaticTaxOptions + { + Enabled = true + } + }); + } - phases.Add(new SubscriptionSchedulePhaseOptions + await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id, + new SubscriptionScheduleUpdateOptions + { + DefaultSettings = new SubscriptionScheduleDefaultSettingsOptions { - StartDate = phase.StartDate, - EndDate = phase.EndDate, - Items = phase.Items.Select(item => new SubscriptionSchedulePhaseItemOptions - { - Price = item.PriceId, - Quantity = item.Quantity - }).ToList(), - Discounts = discountConsumed - ? [] - : customerDiscount.MergeDiscountCouponIds( - phase.Discounts?.Select(d => d.CouponId)).ToPhaseDiscountOptions(), - ProrationBehavior = phase.ProrationBehavior, - AutomaticTax = new SubscriptionSchedulePhaseAutomaticTaxOptions + AutomaticTax = new SubscriptionScheduleDefaultSettingsAutomaticTaxOptions { Enabled = true } - }); - } - - await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id, - new SubscriptionScheduleUpdateOptions - { - DefaultSettings = new SubscriptionScheduleDefaultSettingsOptions - { - AutomaticTax = new SubscriptionScheduleDefaultSettingsAutomaticTaxOptions - { - Enabled = true - } - }, - Phases = phases - }); - return; - } + }, + Phases = phases + }); + return; } await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs index 166240518c48..ccec8fac548a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs @@ -16,7 +16,6 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; public class DeleteClaimedOrganizationUserAccountCommand( - IUserService userService, IEventService eventService, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IOrganizationUserRepository organizationUserRepository, @@ -24,7 +23,6 @@ public class DeleteClaimedOrganizationUserAccountCommand( IPushNotificationService pushService, ILogger logger, IDeleteClaimedOrganizationUserAccountValidator deleteClaimedOrganizationUserAccountValidator, - IFeatureService featureService, ISubscriberService subscriberService) : IDeleteClaimedOrganizationUserAccountCommand { @@ -132,18 +130,11 @@ private async Task CancelPremiumsAsync(IEnumerable { try { - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - // In cases where the subscription is not active, the cancellation will fail and be logged. - await subscriberService.CancelSubscription( - user, - cancelImmediately: false, - offboardingSurveyResponse: new OffboardingSurveyResponse { UserId = user.Id }); - } - else - { - await userService.CancelPremiumAsync(user); - } + // In cases where the subscription is not active, the cancellation will fail and be logged. + await subscriberService.CancelSubscription( + user, + cancelImmediately: false, + offboardingSurveyResponse: new OffboardingSurveyResponse { UserId = user.Id }); } catch (Exception exception) when (exception is GatewayException or BillingException) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs index e7d40a7ce7d5..ebea407403f6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs @@ -18,32 +18,26 @@ public class OrganizationDeleteCommand : IOrganizationDeleteCommand { private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService; private readonly IOrganizationRepository _organizationRepository; - private readonly IStripePaymentService _paymentService; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ICipherService _cipherService; private readonly ISubscriberService _subscriberService; - private readonly IFeatureService _featureService; private readonly ISendFileStorageService _sendFileStorageService; private readonly ILogger _logger; public OrganizationDeleteCommand( IOrganizationAbilityCacheService organizationAbilityCacheService, IOrganizationRepository organizationRepository, - IStripePaymentService paymentService, ISsoConfigRepository ssoConfigRepository, ICipherService cipherService, ISubscriberService subscriberService, - IFeatureService featureService, ISendFileStorageService sendFileStorageService, ILogger logger) { _organizationAbilityCacheService = organizationAbilityCacheService; _organizationRepository = organizationRepository; - _paymentService = paymentService; _ssoConfigRepository = ssoConfigRepository; _cipherService = cipherService; _subscriberService = subscriberService; - _featureService = featureService; _sendFileStorageService = sendFileStorageService; _logger = logger; } @@ -59,15 +53,8 @@ public async Task DeleteAsync(Organization organization) var eop = !organization.ExpirationDate.HasValue || organization.ExpirationDate.Value >= DateTime.UtcNow; - if (_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - // In cases where the subscription is not active, the cancellation will fail and be logged. - await _subscriberService.CancelSubscription(organization, cancelImmediately: !eop); - } - else - { - await _paymentService.CancelSubscriptionAsync(organization, eop); - } + // In cases where the subscription is not active, the cancellation will fail and be logged. + await _subscriberService.CancelSubscription(organization, cancelImmediately: !eop); } catch (Exception exception) when (exception is GatewayException or BillingException) { diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index e96224ff02e4..65c8be50be27 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -12,7 +12,6 @@ namespace Bit.Core.Services; public interface IOrganizationService { - Task ReinstateSubscriptionAsync(Guid organizationId); Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb); Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats); Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 9df532deb440..df4bc0a1728d 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -106,17 +106,6 @@ public OrganizationService( _timeProvider = timeProvider; } - public async Task ReinstateSubscriptionAsync(Guid organizationId) - { - var organization = await GetOrgById(organizationId); - if (organization == null) - { - throw new NotFoundException(); - } - - await _paymentService.ReinstateSubscriptionAsync(organization); - } - public async Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb) { var organization = await GetOrgById(organizationId); diff --git a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs index 852ceb28acb1..04a36d56a543 100644 --- a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs @@ -141,71 +141,68 @@ private async Task EnableAutomaticTaxAsync( if (subscription is { AutomaticTax.Enabled: false }) { - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync( - new SubscriptionScheduleListOptions { Customer = subscription.CustomerId }); + var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync( + new SubscriptionScheduleListOptions { Customer = subscription.CustomerId }); + + var activeSchedule = schedules.Data.FirstOrDefault(s => + s.SubscriptionId == subscription.Id + && s.Status == StripeConstants.SubscriptionScheduleStatus.Active); - var activeSchedule = schedules.Data.FirstOrDefault(s => - s.SubscriptionId == subscription.Id - && s.Status == StripeConstants.SubscriptionScheduleStatus.Active); + if (activeSchedule != null) + { + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + var phases = new List(); - if (activeSchedule != null) + for (var i = 0; i < activeSchedule.Phases.Count; i++) { - var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; - var phases = new List(); + var phase = activeSchedule.Phases[i]; - for (var i = 0; i < activeSchedule.Phases.Count; i++) + if (phase.EndDate <= now) { - var phase = activeSchedule.Phases[i]; + continue; + } - if (phase.EndDate <= now) - { - continue; - } + var discountConsumed = i > 0 && activeSchedule.Phases[i - 1].EndDate <= now; - var discountConsumed = i > 0 && activeSchedule.Phases[i - 1].EndDate <= now; + // Gate on StartDate > now, not !discountConsumed (false for the active + // phase 0), so we never re-stack onto the current period. Use the fetched + // customer (subscription.Customer may be a bare id here). + var customerDiscount = phase.StartDate > now ? customer.Discount : null; - // Gate on StartDate > now, not !discountConsumed (false for the active - // phase 0), so we never re-stack onto the current period. Use the fetched - // customer (subscription.Customer may be a bare id here). - var customerDiscount = phase.StartDate > now ? customer.Discount : null; + phases.Add(new SubscriptionSchedulePhaseOptions + { + StartDate = phase.StartDate, + EndDate = phase.EndDate, + Items = phase.Items.Select(item => new SubscriptionSchedulePhaseItemOptions + { + Price = item.PriceId, + Quantity = item.Quantity + }).ToList(), + Discounts = discountConsumed + ? [] + : customerDiscount.MergeDiscountCouponIds( + phase.Discounts?.Select(d => d.CouponId)).ToPhaseDiscountOptions(), + ProrationBehavior = phase.ProrationBehavior, + AutomaticTax = new SubscriptionSchedulePhaseAutomaticTaxOptions + { + Enabled = true + } + }); + } - phases.Add(new SubscriptionSchedulePhaseOptions + await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id, + new SubscriptionScheduleUpdateOptions + { + DefaultSettings = new SubscriptionScheduleDefaultSettingsOptions { - StartDate = phase.StartDate, - EndDate = phase.EndDate, - Items = phase.Items.Select(item => new SubscriptionSchedulePhaseItemOptions - { - Price = item.PriceId, - Quantity = item.Quantity - }).ToList(), - Discounts = discountConsumed - ? [] - : customerDiscount.MergeDiscountCouponIds( - phase.Discounts?.Select(d => d.CouponId)).ToPhaseDiscountOptions(), - ProrationBehavior = phase.ProrationBehavior, - AutomaticTax = new SubscriptionSchedulePhaseAutomaticTaxOptions + AutomaticTax = new SubscriptionScheduleDefaultSettingsAutomaticTaxOptions { Enabled = true } - }); - } - - await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id, - new SubscriptionScheduleUpdateOptions - { - DefaultSettings = new SubscriptionScheduleDefaultSettingsOptions - { - AutomaticTax = new SubscriptionScheduleDefaultSettingsAutomaticTaxOptions - { - Enabled = true - } - }, - Phases = phases - }); - return; - } + }, + Phases = phases + }); + return; } await stripeAdapter.UpdateSubscriptionAsync(subscriber.GatewaySubscriptionId, diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 3f26e16ce163..cd8b1cd0c74b 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -20,9 +20,8 @@ public interface IPriceIncreaseScheduler /// /// Creates a two-phase subscription schedule that defers a Premium/Families price increase /// to the subscription's renewal date. Phase 1 echoes the current subscription state; - /// Phase 2 applies the new price (and discount where applicable). Gated behind the - /// PM32645_DeferPriceMigrationToRenewal feature flag. No-ops if the flag is off, - /// an active schedule already exists, or the subscription does not match a known personal + /// Phase 2 applies the new price (and discount where applicable). No-ops if an active + /// schedule already exists, or the subscription does not match a known personal /// migration path. /// /// The Stripe subscription to schedule a price increase for. @@ -91,11 +90,6 @@ public class PriceIncreaseScheduler( { public async Task SchedulePersonalPriceIncrease(Subscription subscription) { - if (!featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - return false; - } - if (await ActiveScheduleExistsAsync(subscription)) { return false; @@ -350,11 +344,6 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, private async Task ResolvePersonalPhase2Async(Subscription subscription) { - if (!featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - return null; - } - // Stripe.NET deserializes an unexpanded "discounts" array as a list of null entries; // proceeding would silently drop pre-existing discounts from Phase 2. if (subscription.Discounts is { Count: > 0 } && subscription.Discounts.Any(d => d == null)) diff --git a/src/Core/Billing/Services/IStripePaymentService.cs b/src/Core/Billing/Services/IStripePaymentService.cs index fb3d1f0b05ae..ea6318c5f6a5 100644 --- a/src/Core/Billing/Services/IStripePaymentService.cs +++ b/src/Core/Billing/Services/IStripePaymentService.cs @@ -36,8 +36,6 @@ Task AdjustSubscription( Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId); Task AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts); - Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false); - Task ReinstateSubscriptionAsync(ISubscriber subscriber); Task CreditAccountAsync(ISubscriber subscriber, decimal creditAmount); Task GetBillingAsync(ISubscriber subscriber); Task GetBillingHistoryAsync(ISubscriber subscriber); diff --git a/src/Core/Billing/Services/Implementations/StripePaymentService.cs b/src/Core/Billing/Services/Implementations/StripePaymentService.cs index 3ab7946288a8..c8f608a4ecf6 100644 --- a/src/Core/Billing/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Billing/Services/Implementations/StripePaymentService.cs @@ -509,83 +509,6 @@ await _stripeAdapter.UpdateCustomerAsync(customer.Id, return paymentIntentClientSecret; } - public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false) - { - if (subscriber == null) - { - throw new ArgumentNullException(nameof(subscriber)); - } - - if (string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) - { - throw new GatewayException("No subscription."); - } - - var sub = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); - if (sub == null) - { - throw new GatewayException("Subscription was not found."); - } - - if (sub.CanceledAt.HasValue || sub.Status == "canceled" || sub.Status == "unpaid" || - sub.Status == "incomplete_expired") - { - // Already canceled - return; - } - - try - { - var canceledSub = endOfPeriod - ? await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, - new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) - : await _stripeAdapter.CancelSubscriptionAsync(sub.Id, new SubscriptionCancelOptions()); - if (!canceledSub.CanceledAt.HasValue) - { - throw new GatewayException("Unable to cancel subscription."); - } - } - catch (StripeException e) - { - if (e.Message != $"No such subscription: {subscriber.GatewaySubscriptionId}") - { - throw; - } - } - } - - public async Task ReinstateSubscriptionAsync(ISubscriber subscriber) - { - if (subscriber == null) - { - throw new ArgumentNullException(nameof(subscriber)); - } - - if (string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) - { - throw new GatewayException("No subscription."); - } - - var sub = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); - if (sub == null) - { - throw new GatewayException("Subscription was not found."); - } - - if ((sub.Status != "active" && sub.Status != "trialing" && !sub.Status.StartsWith("incomplete")) || - !sub.CanceledAt.HasValue) - { - throw new GatewayException("Subscription is not marked for cancellation."); - } - - var updatedSub = await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, - new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); - if (updatedSub.CanceledAt.HasValue) - { - throw new GatewayException("Unable to reinstate subscription."); - } - } - public async Task CreditAccountAsync(ISubscriber subscriber, decimal creditAmount) { Customer customer = null; diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 5df1a8d727a8..9bc8ffc1e849 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -228,8 +228,7 @@ private async Task CancelSubscriptionImmediatelyAsync( SubscriptionCancellationDetailsOptions? cancellationDetails, Dictionary? cancellingUserMetadata) { - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) || - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) + if (featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { var activeSchedule = await GetActiveScheduleAsync(subscription); if (activeSchedule != null) @@ -264,8 +263,7 @@ private async Task CancelSubscriptionAtPeriodEndAsync( Metadata = cancellingUserMetadata }; - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) || - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) + if (featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { var activeSchedule = await GetActiveScheduleAsync(subscription); diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs index 5910d2bf7113..05abb8df7d08 100644 --- a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -41,8 +41,7 @@ public Task> Run(ISubscriber subscriber) => HandleAsy return new BadRequest("Subscription is not pending cancellation."); } - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) || - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) + if (featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { if (subscription.Metadata?.ContainsKey(MetadataKeys.CancelledDuringDeferredPriceIncrease) == true) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 27b4a25dfff6..123f94812661 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -184,7 +184,6 @@ public static class FeatureFlagKeys public const string PM29108_EnablePersonalDiscounts = "pm-29108-enable-personal-discounts"; public const string PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade"; public const string PM32581_UseUpdateOrganizationSubscriptionCommand = "pm-32581-use-update-organization-subscription-command"; - public const string PM32645_DeferPriceMigrationToRenewal = "pm-32645-defer-price-migration-to-renewal"; public const string PM34515_BrowserDesktopCheckout = "pm-34515-browser-desktop-checkout"; public const string DebugDisableSelfHostPremiumCheck = "debug-disable-self-host-premium-check"; public const string PM35215_BusinessPlanPriceMigration = "pm-35215-business-plan-price-migration"; diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index ce9c6328bc91..7666f0fe66cd 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -44,7 +44,6 @@ Task ChangeEmailAsync(User user, string masterPassword, string n Task DeleteAsync(User user, string token); Task SendDeleteConfirmationAsync(string email); Task UpdateLicenseAsync(User user, UserLicense license); - Task CancelPremiumAsync(User user, bool? endOfPeriod = null); Task EnablePremiumAsync(Guid userId, DateTime? expirationDate); Task DisablePremiumAsync(Guid userId, DateTime? expirationDate); Task UpdatePremiumExpirationAsync(Guid userId, DateTime? expirationDate); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 446853583f67..0b577bd1e1ca 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -62,7 +62,6 @@ public class UserService : UserManager, IUserService private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; private readonly IProviderUserRepository _providerUserRepository; private readonly IStripeSyncService _stripeSyncService; - private readonly IFeatureService _featureService; private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IDistributedCache _distributedCache; @@ -96,7 +95,6 @@ public UserService( IAcceptOrgUserCommand acceptOrgUserCommand, IProviderUserRepository providerUserRepository, IStripeSyncService stripeSyncService, - IFeatureService featureService, IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IDistributedCache distributedCache, @@ -134,7 +132,6 @@ public UserService( _acceptOrgUserCommand = acceptOrgUserCommand; _providerUserRepository = providerUserRepository; _stripeSyncService = stripeSyncService; - _featureService = featureService; _revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _distributedCache = distributedCache; @@ -267,17 +264,10 @@ public override async Task DeleteAsync(User user) { try { - if (_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - await _subscriberService.CancelSubscription( - user, - cancelImmediately: false, - offboardingSurveyResponse: new OffboardingSurveyResponse { UserId = user.Id }); - } - else - { - await CancelPremiumAsync(user); - } + await _subscriberService.CancelSubscription( + user, + cancelImmediately: false, + offboardingSurveyResponse: new OffboardingSurveyResponse { UserId = user.Id }); } catch (GatewayException) { } catch (BillingException) { } @@ -798,18 +788,6 @@ public async Task UpdateLicenseAsync(User user, UserLicense license) await SaveUserAsync(user); } - //TODO: Remove with the deletion of PM32645_DeferPriceMigrationToRenewal feature flag - public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null) - { - var eop = endOfPeriod.GetValueOrDefault(true); - if (!endOfPeriod.HasValue && user.PremiumExpirationDate.HasValue && - user.PremiumExpirationDate.Value < DateTime.UtcNow) - { - eop = false; - } - await _paymentService.CancelSubscriptionAsync(user, eop); - } - public async Task EnablePremiumAsync(Guid userId, DateTime? expirationDate) { var user = await _userRepository.GetByIdAsync(userId); diff --git a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs index 2e7afabd5f01..5fc86c559972 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs @@ -54,7 +54,6 @@ public class OrganizationsControllerTests : IDisposable private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IOrganizationInstallationRepository _organizationInstallationRepository; private readonly IPricingClient _pricingClient; - private readonly IFeatureService _featureService; private readonly IReinstateSubscriptionCommand _reinstateSubscriptionCommand; private readonly OrganizationsController _sut; @@ -80,7 +79,6 @@ public OrganizationsControllerTests() _removeOrganizationUserCommand = Substitute.For(); _organizationInstallationRepository = Substitute.For(); _pricingClient = Substitute.For(); - _featureService = Substitute.For(); _reinstateSubscriptionCommand = Substitute.For(); _sut = new OrganizationsController( @@ -99,7 +97,6 @@ public OrganizationsControllerTests() _subscriberService, _organizationInstallationRepository, _pricingClient, - _featureService, _reinstateSubscriptionCommand); } @@ -299,14 +296,11 @@ await _addSecretsManagerSubscriptionCommand.Received(1) } [Theory, AutoData] - public async Task PostReinstate_WhenFlagEnabled_CallsReinstateCommand(Guid organizationId) + public async Task PostReinstate_CallsReinstateCommand(Guid organizationId) { var organization = new Organization { Id = organizationId, GatewaySubscriptionId = "sub_123" }; _currentContext.EditSubscription(organizationId).Returns(true); - _featureService - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); _organizationRepository.GetByIdAsync(organizationId).Returns(organization); _reinstateSubscriptionCommand .Run(organization) @@ -315,30 +309,12 @@ public async Task PostReinstate_WhenFlagEnabled_CallsReinstateCommand(Guid organ await _sut.PostReinstate(organizationId); await _reinstateSubscriptionCommand.Received(1).Run(organization); - await _organizationService.DidNotReceiveWithAnyArgs().ReinstateSubscriptionAsync(default); } [Theory, AutoData] - public async Task PostReinstate_WhenFlagDisabled_CallsLegacyOrganizationService(Guid organizationId) + public async Task PostReinstate_AndOrgNotFound_ThrowsNotFoundException(Guid organizationId) { _currentContext.EditSubscription(organizationId).Returns(true); - _featureService - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(false); - - await _sut.PostReinstate(organizationId); - - await _organizationService.Received(1).ReinstateSubscriptionAsync(organizationId); - await _reinstateSubscriptionCommand.DidNotReceiveWithAnyArgs().Run(default); - } - - [Theory, AutoData] - public async Task PostReinstate_WhenFlagEnabled_AndOrgNotFound_ThrowsNotFoundException(Guid organizationId) - { - _currentContext.EditSubscription(organizationId).Returns(true); - _featureService - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); _organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); await Assert.ThrowsAsync(() => _sut.PostReinstate(organizationId)); diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index cbede597a4c5..d893e8a496b0 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -1464,7 +1464,7 @@ public static IEnumerable GetValidTransitionToActiveSubscriptions() } [Fact] - public async Task HandleAsync_ScheduleTriggeredFamiliesMigration_FlagOn_UpdatesOrganization() + public async Task HandleAsync_ScheduleTriggeredFamiliesMigration_UpdatesOrganization() { // Arrange — Families 2019 → FamiliesAnnually migration via schedule var organizationId = Guid.NewGuid(); @@ -1521,8 +1521,6 @@ public async Task HandleAsync_ScheduleTriggeredFamiliesMigration_FlagOn_UpdatesO _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); @@ -1544,58 +1542,7 @@ await _organizationRepository.Received(1).ReplaceAsync( } [Fact] - public async Task HandleAsync_ScheduleTriggeredMigration_FlagOff_DoesNotUpdateOrganization() - { - // Arrange - var organizationId = Guid.NewGuid(); - - var subscription = new Subscription - { - Id = "sub_123", - Status = SubscriptionStatus.Active, - ScheduleId = "sub_sched_123", - Items = new StripeList - { - Data = [new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(365) }] - }, - Metadata = new Dictionary - { - { "organizationId", organizationId.ToString() } - } - }; - - var parsedEvent = new Event - { - Data = new EventData - { - Object = subscription, - PreviousAttributes = JObject.FromObject(new - { - items = new - { - data = new[] { new { price = new { id = "personal-org-annually" } } } - } - }) - } - }; - - _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) - .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); - - // Assert - await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); - } - - [Fact] - public async Task HandleAsync_NoSchedule_FlagOn_DoesNotUpdateOrganization() + public async Task HandleAsync_NoSchedule_DoesNotUpdateOrganization() { // Arrange — no ScheduleId means this isn't a schedule transition var organizationId = Guid.NewGuid(); @@ -1632,8 +1579,6 @@ public async Task HandleAsync_NoSchedule_FlagOn_DoesNotUpdateOrganization() _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .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()); @@ -1694,8 +1639,6 @@ public async Task HandleAsync_ScheduleTriggered_PreviousPriceNotOldFamilies_Does _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); @@ -1754,8 +1697,6 @@ public async Task HandleAsync_ScheduleTriggered_CurrentPriceNotNewFamilies_DoesN _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); _organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually }); @@ -1798,8 +1739,6 @@ public async Task HandleAsync_ScheduleTriggered_NoItemChanges_DoesNotUpdateOrgan _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); _organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId }); // Act @@ -1857,8 +1796,6 @@ public async Task HandleAsync_ScheduleTriggeredMigration_WhenOrganizationNotFoun _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); @@ -1934,8 +1871,6 @@ public async Task HandleAsync_ScheduleTriggered_MultipleItems_MatchesFamiliesPri _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index fb0b5c3cdd29..381f5b891c86 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -121,190 +121,6 @@ public async Task HandleAsync_WhenNullSubscription_DoesNothing() await _stripeAdapter.DidNotReceive() .UpdateCustomerAsync(Arg.Any(), Arg.Any()); } - - [Fact] - public async Task HandleAsync_WhenValidUser_SendsEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123" }; - var customerId = "cus_123"; - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 10000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - var subscription = new Subscription - { - Id = "sub_123", - CustomerId = customerId, - Items = new StripeList - { - Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, - Customer = new Customer { Id = customerId }, - Metadata = new Dictionary() - }; - var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; - var plan = new PremiumPlan - { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var oldPlan = new PremiumPlan - { - Name = "Premium (Old)", - Available = false, - LegacyYear = 2023, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var customer = new Customer - { - Id = customerId, - Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, - Subscriptions = new StripeList { Data = [subscription] } - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter - .GetCustomerAsync(customerId, Arg.Any()) - .Returns(customer); - - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(null, _userId, null)); - - _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - await _userRepository.Received(1).GetByIdAsync(_userId); - - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("user@example.com")), - Arg.Is(amount => amount == invoice.AmountDue / 100M), - Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), - Arg.Is>(items => items.Count == invoice.Lines.Data.Count), - Arg.Is(b => b == true)); - } - - [Fact] - public async Task - HandleAsync_WhenUserValid_AndMilestone2Enabled_UpdatesPriceId_AndSendsUpdatedInvoiceUpcomingEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123" }; - var customerId = "cus_123"; - var priceSubscriptionId = "sub-1"; - var priceId = "price-id-2"; - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 10000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - var subscription = new Subscription - { - Id = "sub_123", - CustomerId = customerId, - Items = new StripeList - { - Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, - Customer = new Customer - { - Id = customerId, - Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported } - }, - Metadata = new Dictionary() - }; - var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; - var plan = new PremiumPlan - { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var oldPlan = new PremiumPlan - { - Name = "Premium (Old)", - Available = false, - LegacyYear = 2023, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] } - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter - .GetCustomerAsync(customerId, Arg.Any()) - .Returns(customer); - - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(null, _userId, null)); - - _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); - _stripeAdapter.UpdateSubscriptionAsync( - subscription.Id, - Arg.Any()) - .Returns(subscription); - - var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount }; - - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - await _userRepository.Received(1).GetByIdAsync(_userId); - await _pricingClient.Received(1).ListPremiumPlans(); - await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount); - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - Arg.Is("sub_123"), - Arg.Is(o => - o.Items[0].Id == priceSubscriptionId && - o.Items[0].Price == priceId && - o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount && - o.ProrationBehavior == "none")); - - // Verify the updated invoice email was sent with correct price - var discountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100; - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("user@example.com") && - email.Subject == "Your Bitwarden Premium renewal is updating" && - email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) && - email.View.DiscountedAnnualRenewalPrice == discountedPrice.ToString("C", new CultureInfo("en-US")) && - email.View.DiscountAmount == $"{coupon.PercentOff}%" - )); - } - [Fact] public async Task HandleAsync_WhenOrganizationHasSponsorship_SendsEmail() { @@ -1023,106 +839,6 @@ await _stripeAdapter.Received(1).UpdateCustomerAsync( Arg.Is("cus_123"), Arg.Is(o => o.TaxExempt == TaxExempt.None)); } - - [Fact] - public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsTraditionalEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123" }; - var customerId = "cus_123"; - var priceSubscriptionId = "sub-1"; - var priceId = "price-id-2"; - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 10000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - var subscription = new Subscription - { - Id = "sub_123", - CustomerId = customerId, - Items = new StripeList - { - Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Customer = new Customer - { - Id = customerId, - Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported } - }, - Metadata = new Dictionary() - }; - var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; - var plan = new PremiumPlan - { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var oldPlan = new PremiumPlan - { - Name = "Premium (Old)", - Available = false, - LegacyYear = 2023, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] } - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); - - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(null, _userId, null)); - - _userRepository.GetByIdAsync(_userId).Returns(user); - - _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); - - // Setup exception when updating subscription - _stripeAdapter - .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new Exception()); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - _logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => - o.ToString() - .Contains( - $"Failed to update user's ({user.Id}) subscription price id while processing event with ID {parsedEvent.Id}")), - Arg.Any(), - Arg.Any>()); - - // Verify that traditional email was sent when update fails - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("user@example.com")), - Arg.Is(amount => amount == invoice.AmountDue / 100M), - Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), - Arg.Is>(items => items.Count == invoice.Lines.Data.Count), - Arg.Is(b => b == true)); - - // Verify renewal email was NOT sent - await _mailer.DidNotReceive().SendEmail(Arg.Any()); - } - [Fact] public async Task HandleAsync_WhenOrganizationNotFound_DoesNothing() { @@ -1354,16 +1070,13 @@ await _mailService.DidNotReceive().SendProviderInvoiceUpcoming( Arg.Any(), Arg.Any()); } - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesSubscriptionAndOrganization() + public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesNotUpdateSubscription() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; var customerId = "cus_123"; var subscriptionId = "sub_123"; - var passwordManagerItemId = "si_pm_123"; - var premiumAccessItemId = "si_premium_123"; var invoice = new Invoice { @@ -1376,7 +1089,6 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS } }; - var families2019Plan = new Families2019Plan(); var familiesPlan = new FamiliesPlan(); var subscription = new Subscription @@ -1387,16 +1099,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS { Data = [ - new() - { - Id = passwordManagerItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } - }, - new() - { - Id = premiumAccessItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } - } + new() { Id = "si_pm_123", Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } } ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, @@ -1414,57 +1117,27 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS { Id = _organizationId, BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually2019 + PlanType = PlanType.FamiliesAnnually // Already on the new plan }; - var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); // Act await _sut.HandleAsync(parsedEvent); - // Assert - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - Arg.Is(subscriptionId), - Arg.Is(o => - o.Items.Count == 2 && - o.Items[0].Id == passwordManagerItemId && - o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && - o.Items[1].Id == premiumAccessItemId && - o.Items[1].Deleted == true && - o.Discounts.Count == 1 && - o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && - o.ProrationBehavior == ProrationBehavior.None)); - - await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount); - - await _organizationRepository.Received(1).ReplaceAsync( - Arg.Is(org => - org.Id == _organizationId && - org.PlanType == PlanType.FamiliesAnnually && - org.Plan == familiesPlan.Name && - org.UsersGetPremium == familiesPlan.UsersGetPremium && - org.Seats == familiesPlan.PasswordManager.BaseSeats)); - - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Bitwarden Families subscription is updating" && - email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && - email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && - email.View.DiscountAmount == $"{coupon.PercentOff}%" - )); + // Assert - should not update subscription when not on FamiliesAnnually2019 plan + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); // Families plan is excluded from tax exempt alignment await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( Arg.Any(), @@ -1472,175 +1145,7 @@ await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( } [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutPremiumAccess_UpdatesSubscriptionAndOrganization() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; - var customerId = "cus_123"; - var subscriptionId = "sub_123"; - var passwordManagerItemId = "si_pm_123"; - - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 40000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - - var families2019Plan = new Families2019Plan(); - var familiesPlan = new FamiliesPlan(); - - var subscription = new Subscription - { - Id = subscriptionId, - CustomerId = customerId, - Items = new StripeList - { - Data = - [ - new() - { - Id = passwordManagerItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } - } - ] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Metadata = new Dictionary() - }; - - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] }, - Address = new Address { Country = "US" } - }; - - var organization = new Organization - { - Id = _organizationId, - BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually2019 - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(_organizationId, null, null)); - _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); - _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - Arg.Is(subscriptionId), - Arg.Is(o => - o.Items.Count == 1 && - o.Items[0].Id == passwordManagerItemId && - o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && - o.Discounts.Count == 1 && - o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && - o.ProrationBehavior == ProrationBehavior.None)); - - await _organizationRepository.Received(1).ReplaceAsync( - Arg.Is(org => - org.Id == _organizationId && - org.PlanType == PlanType.FamiliesAnnually && - org.Plan == familiesPlan.Name && - org.UsersGetPremium == familiesPlan.UsersGetPremium && - org.Seats == familiesPlan.PasswordManager.BaseSeats)); - - // Families plan is excluded from tax exempt alignment - await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesNotUpdateSubscription() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; - var customerId = "cus_123"; - var subscriptionId = "sub_123"; - - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 40000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - - var familiesPlan = new FamiliesPlan(); - - var subscription = new Subscription - { - Id = subscriptionId, - CustomerId = customerId, - Items = new StripeList - { - Data = - [ - new() { Id = "si_pm_123", Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } } - ] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Metadata = new Dictionary() - }; - - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] }, - Address = new Address { Country = "US" } - }; - - var organization = new Organization - { - Id = _organizationId, - BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually // Already on the new plan - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(_organizationId, null, null)); - _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); - _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - should not update subscription when not on FamiliesAnnually2019 plan - await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( - Arg.Any(), - Arg.Is(o => o.Discounts != null)); - - await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); - // Families plan is excluded from tax exempt alignment - await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFound_LogsWarning() + public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFound_LogsWarning() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; @@ -1718,1092 +1223,8 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); } - - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsErrorAndSendsTraditionalEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; - var customerId = "cus_123"; - var subscriptionId = "sub_123"; - var passwordManagerItemId = "si_pm_123"; - - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 40000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - - var families2019Plan = new Families2019Plan(); - var familiesPlan = new FamiliesPlan(); - - var subscription = new Subscription - { - Id = subscriptionId, - CustomerId = customerId, - Items = new StripeList - { - Data = - [ - new() - { - Id = passwordManagerItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } - } - ] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Metadata = new Dictionary() - }; - - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] }, - Address = new Address { Country = "US" } - }; - - var organization = new Organization - { - Id = _organizationId, - BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually2019 - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(_organizationId, null, null)); - _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); - _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - - // Simulate update failure - _stripeAdapter - .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new Exception("Stripe API error")); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - _logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => - o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") && - o.ToString().Contains(parsedEvent.Type) && - o.ToString().Contains(parsedEvent.Id)), - Arg.Any(), - Arg.Any>()); - - // Should send traditional email when update fails - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), - Arg.Is(amount => amount == invoice.AmountDue / 100M), - Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), - Arg.Is>(items => items.Count == invoice.Lines.Data.Count), - Arg.Is(b => b == true)); - - // Verify renewal email was NOT sent - await _mailer.DidNotReceive().SendEmail(Arg.Any()); - } - - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; - var customerId = "cus_123"; - var subscriptionId = "sub_123"; - var passwordManagerItemId = "si_pm_123"; - - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 40000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - - var families2019Plan = new Families2019Plan(); - var familiesPlan = new FamiliesPlan(); - - var subscription = new Subscription - { - Id = subscriptionId, - CustomerId = customerId, - Items = new StripeList - { - Data = - [ - new() - { - Id = passwordManagerItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } - } - ] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Metadata = new Dictionary() - }; - - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] }, - Address = new Address { Country = "US" } - }; - - var organization = new Organization - { - Id = _organizationId, - BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually2019 - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(_organizationId, null, null)); - _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); - _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) - .Returns(subscription); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - Exception is caught, error is logged, and traditional email is sent - _logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => - o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") && - o.ToString().Contains(parsedEvent.Type) && - o.ToString().Contains(parsedEvent.Id)), - Arg.Is(e => e is InvalidOperationException && e.Message.Contains("Coupon for sending families 2019 email")), - Arg.Any>()); - - await _mailer.DidNotReceive().SendEmail(Arg.Any()); - - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), - Arg.Is(amount => amount == invoice.AmountDue / 100M), - Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), - Arg.Is>(items => items.Count == invoice.Lines.Data.Count), - Arg.Is(b => b == true)); - } - - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; - var customerId = "cus_123"; - var subscriptionId = "sub_123"; - var passwordManagerItemId = "si_pm_123"; - - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 40000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - - var families2019Plan = new Families2019Plan(); - var familiesPlan = new FamiliesPlan(); - - var subscription = new Subscription - { - Id = subscriptionId, - CustomerId = customerId, - Items = new StripeList - { - Data = - [ - new() - { - Id = passwordManagerItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } - } - ] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Metadata = new Dictionary() - }; - - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] }, - Address = new Address { Country = "US" } - }; - - var organization = new Organization - { - Id = _organizationId, - BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually2019 - }; - - var coupon = new Coupon - { - Id = CouponIDs.Milestone3SubscriptionDiscount, - PercentOff = null - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(_organizationId, null, null)); - _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); - _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) - .Returns(subscription); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - Exception is caught, error is logged, and traditional email is sent - _logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => - o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") && - o.ToString().Contains(parsedEvent.Type) && - o.ToString().Contains(parsedEvent.Id)), - Arg.Is(e => e is InvalidOperationException && e.Message.Contains("coupon.PercentOff")), - Arg.Any>()); - - await _mailer.DidNotReceive().SendEmail(Arg.Any()); - - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), - Arg.Is(amount => amount == invoice.AmountDue / 100M), - Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), - Arg.Is>(items => items.Count == invoice.Lines.Data.Count), - Arg.Is(b => b == true)); - } - - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; - var customerId = "cus_123"; - var subscriptionId = "sub_123"; - var passwordManagerItemId = "si_pm_123"; - var seatAddOnItemId = "si_seat_123"; - - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 40000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - - var families2019Plan = new Families2019Plan(); - var familiesPlan = new FamiliesPlan(); - - var subscription = new Subscription - { - Id = subscriptionId, - CustomerId = customerId, - Items = new StripeList - { - Data = - [ - new() - { - Id = passwordManagerItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } - }, - - new() - { - Id = seatAddOnItemId, - Price = new Price { Id = "personal-org-seat-annually" }, - Quantity = 3 - } - ] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Metadata = new Dictionary() - }; - - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] }, - Address = new Address { Country = "US" } - }; - - var organization = new Organization - { - Id = _organizationId, - BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually2019 - }; - - var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(_organizationId, null, null)); - _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); - _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - Arg.Is(subscriptionId), - Arg.Is(o => - o.Items.Count == 2 && - o.Items[0].Id == passwordManagerItemId && - o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && - o.Items[1].Id == seatAddOnItemId && - o.Items[1].Deleted == true && - o.Discounts.Count == 1 && - o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && - o.ProrationBehavior == ProrationBehavior.None)); - - await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount); - - await _organizationRepository.Received(1).ReplaceAsync( - Arg.Is(org => - org.Id == _organizationId && - org.PlanType == PlanType.FamiliesAnnually && - org.Plan == familiesPlan.Name && - org.UsersGetPremium == familiesPlan.UsersGetPremium && - org.Seats == familiesPlan.PasswordManager.BaseSeats)); - - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Bitwarden Families subscription is updating" && - email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && - email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && - email.View.DiscountAmount == $"{coupon.PercentOff}%" - )); - } - - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_DeletesItem() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; - var customerId = "cus_123"; - var subscriptionId = "sub_123"; - var passwordManagerItemId = "si_pm_123"; - var seatAddOnItemId = "si_seat_123"; - - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 40000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - - var families2019Plan = new Families2019Plan(); - var familiesPlan = new FamiliesPlan(); - - var subscription = new Subscription - { - Id = subscriptionId, - CustomerId = customerId, - Items = new StripeList - { - Data = - [ - new() - { - Id = passwordManagerItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } - }, - - new() - { - Id = seatAddOnItemId, - Price = new Price { Id = "personal-org-seat-annually" }, - Quantity = 1 - } - ] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Metadata = new Dictionary() - }; - - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] }, - Address = new Address { Country = "US" } - }; - - var organization = new Organization - { - Id = _organizationId, - BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually2019 - }; - - var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(_organizationId, null, null)); - _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); - _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - Arg.Is(subscriptionId), - Arg.Is(o => - o.Items.Count == 2 && - o.Items[0].Id == passwordManagerItemId && - o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && - o.Items[1].Id == seatAddOnItemId && - o.Items[1].Deleted == true && - o.Discounts.Count == 1 && - o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && - o.ProrationBehavior == ProrationBehavior.None)); - - await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount); - - await _organizationRepository.Received(1).ReplaceAsync( - Arg.Is(org => - org.Id == _organizationId && - org.PlanType == PlanType.FamiliesAnnually && - org.Plan == familiesPlan.Name && - org.UsersGetPremium == familiesPlan.UsersGetPremium && - org.Seats == familiesPlan.PasswordManager.BaseSeats)); - - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Bitwarden Families subscription is updating" && - email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && - email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && - email.View.DiscountAmount == $"{coupon.PercentOff}%" - )); - } - - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddOn_UpdatesBothItems() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; - var customerId = "cus_123"; - var subscriptionId = "sub_123"; - var passwordManagerItemId = "si_pm_123"; - var premiumAccessItemId = "si_premium_123"; - var seatAddOnItemId = "si_seat_123"; - - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 40000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - - var families2019Plan = new Families2019Plan(); - var familiesPlan = new FamiliesPlan(); - - var subscription = new Subscription - { - Id = subscriptionId, - CustomerId = customerId, - Items = new StripeList - { - Data = - [ - new() - { - Id = passwordManagerItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } - }, - - new() - { - Id = premiumAccessItemId, - Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } - }, - - new() - { - Id = seatAddOnItemId, - Price = new Price { Id = "personal-org-seat-annually" }, - Quantity = 2 - } - ] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Metadata = new Dictionary() - }; - - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] }, - Address = new Address { Country = "US" } - }; - - var organization = new Organization - { - Id = _organizationId, - BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually2019 - }; - - var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(_organizationId, null, null)); - _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); - _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - Arg.Is(subscriptionId), - Arg.Is(o => - o.Items.Count == 3 && - o.Items[0].Id == passwordManagerItemId && - o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && - o.Items[1].Id == premiumAccessItemId && - o.Items[1].Deleted == true && - o.Items[2].Id == seatAddOnItemId && - o.Items[2].Deleted == true && - o.Discounts.Count == 1 && - o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && - o.ProrationBehavior == ProrationBehavior.None)); - - await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount); - - await _organizationRepository.Received(1).ReplaceAsync( - Arg.Is(org => - org.Id == _organizationId && - org.PlanType == PlanType.FamiliesAnnually && - org.Plan == familiesPlan.Name && - org.UsersGetPremium == familiesPlan.UsersGetPremium && - org.Seats == familiesPlan.PasswordManager.BaseSeats)); - - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Bitwarden Families subscription is updating" && - email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && - email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && - email.View.DiscountAmount == $"{coupon.PercentOff}%" - )); - } - - [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2025Plan_UpdatesSubscriptionOnlyNoAddons() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; - var customerId = "cus_123"; - var subscriptionId = "sub_123"; - var passwordManagerItemId = "si_pm_123"; - - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 40000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - - var families2025Plan = new Families2025Plan(); - var familiesPlan = new FamiliesPlan(); - - var subscription = new Subscription - { - Id = subscriptionId, - CustomerId = customerId, - Items = new StripeList - { - Data = - [ - new() - { - Id = passwordManagerItemId, - Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId } - } - ] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, - Metadata = new Dictionary() - }; - - var customer = new Customer - { - Id = customerId, - Subscriptions = new StripeList { Data = [subscription] }, - Address = new Address { Country = "US" } - }; - - var organization = new Organization - { - Id = _organizationId, - BillingEmail = "org@example.com", - PlanType = PlanType.FamiliesAnnually2025 - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService - .GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(_organizationId, null, null)); - _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); - _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - Arg.Is(subscriptionId), - Arg.Is(o => - o.Items.Count == 1 && - o.Items[0].Id == passwordManagerItemId && - o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && - o.Discounts == null && - o.ProrationBehavior == ProrationBehavior.None)); - - await _organizationRepository.Received(1).ReplaceAsync( - Arg.Is(org => - org.Id == _organizationId && - org.PlanType == PlanType.FamiliesAnnually && - org.Plan == familiesPlan.Name && - org.UsersGetPremium == familiesPlan.UsersGetPremium && - org.Seats == familiesPlan.PasswordManager.BaseSeats)); - - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); - } - - [Fact] - public async Task HandleAsync_WhenMilestone2Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123" }; - var customerId = "cus_123"; - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 10000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - var subscription = new Subscription - { - Id = "sub_123", - CustomerId = customerId, - Items = new StripeList - { - Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, - Customer = new Customer { Id = customerId }, - Metadata = new Dictionary() - }; - var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; - var plan = new PremiumPlan - { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var oldPlan = new PremiumPlan - { - Name = "Premium (Old)", - Available = false, - LegacyYear = 2023, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var customer = new Customer - { - Id = customerId, - Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, - Subscriptions = new StripeList { Data = [subscription] } - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(null, _userId, null)); - _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) - .Returns(subscription); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - Exception is caught, error is logged, and traditional email is sent - _logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => - o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") && - o.ToString().Contains(parsedEvent.Id)), - Arg.Is(e => e is InvalidOperationException - && e.Message == $"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"), - Arg.Any>()); - - await _mailer.DidNotReceive().SendEmail(Arg.Any()); - - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("user@example.com")), - Arg.Is(amount => amount == invoice.AmountDue / 100M), - Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), - Arg.Is>(items => items.Count == invoice.Lines.Data.Count), - Arg.Is(b => b == true)); - } - - [Fact] - public async Task HandleAsync_WhenMilestone2Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123" }; - var customerId = "cus_123"; - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 10000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - var subscription = new Subscription - { - Id = "sub_123", - CustomerId = customerId, - Items = new StripeList - { - Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, - Customer = new Customer { Id = customerId }, - Metadata = new Dictionary() - }; - var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; - var plan = new PremiumPlan - { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var oldPlan = new PremiumPlan - { - Name = "Premium (Old)", - Available = false, - LegacyYear = 2023, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var customer = new Customer - { - Id = customerId, - Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, - Subscriptions = new StripeList { Data = [subscription] } - }; - var coupon = new Coupon - { - Id = CouponIDs.Milestone2SubscriptionDiscount, - PercentOff = null - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(null, _userId, null)); - _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) - .Returns(subscription); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - Exception is caught, error is logged, and traditional email is sent - _logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => - o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") && - o.ToString().Contains(parsedEvent.Id)), - Arg.Is(e => e is InvalidOperationException - && e.Message == $"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"), - Arg.Any>()); - - await _mailer.DidNotReceive().SendEmail(Arg.Any()); - - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("user@example.com")), - Arg.Is(amount => amount == invoice.AmountDue / 100M), - Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), - Arg.Is>(items => items.Count == invoice.Lines.Data.Count), - Arg.Is(b => b == true)); - } - - [Fact] - public async Task HandleAsync_WhenMilestone2Enabled_AndValidCoupon_SendsPremiumRenewalEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123" }; - var customerId = "cus_123"; - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 10000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - var subscription = new Subscription - { - Id = "sub_123", - CustomerId = customerId, - Items = new StripeList - { - Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, - Customer = new Customer { Id = customerId }, - Metadata = new Dictionary() - }; - var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; - var plan = new PremiumPlan - { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var oldPlan = new PremiumPlan - { - Name = "Premium (Old)", - Available = false, - LegacyYear = 2023, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var customer = new Customer - { - Id = customerId, - Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, - Subscriptions = new StripeList { Data = [subscription] } - }; - var coupon = new Coupon - { - Id = CouponIDs.Milestone2SubscriptionDiscount, - PercentOff = 30 - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(null, _userId, null)); - _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) - .Returns(subscription); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - var expectedDiscountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100; - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("user@example.com") && - email.Subject == "Your Bitwarden Premium renewal is updating" && - email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) && - email.View.DiscountAmount == "30%" && - email.View.DiscountedAnnualRenewalPrice == expectedDiscountedPrice.ToString("C", new CultureInfo("en-US")) - )); - - await _mailService.DidNotReceive().SendInvoiceUpcoming( - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any>(), - Arg.Any()); - } - - [Fact] - public async Task HandleAsync_WhenMilestone2Enabled_AndGetCouponThrowsException_LogsErrorAndSendsTraditionalEmail() - { - // Arrange - var parsedEvent = new Event { Id = "evt_123" }; - var customerId = "cus_123"; - var invoice = new Invoice - { - CustomerId = customerId, - AmountDue = 10000, - NextPaymentAttempt = DateTime.UtcNow.AddDays(7), - Lines = new StripeList - { - Data = [new() { Description = "Test Item" }] - } - }; - var subscription = new Subscription - { - Id = "sub_123", - CustomerId = customerId, - Items = new StripeList - { - Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] - }, - AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, - Customer = new Customer { Id = customerId }, - Metadata = new Dictionary() - }; - var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; - var plan = new PremiumPlan - { - Name = "Premium", - Available = true, - LegacyYear = null, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var oldPlan = new PremiumPlan - { - Name = "Premium (Old)", - Available = false, - LegacyYear = 2023, - Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } - }; - var customer = new Customer - { - Id = customerId, - Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, - Subscriptions = new StripeList { Data = [subscription] } - }; - - _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); - _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) - .Returns(new Tuple(null, _userId, null)); - _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); - _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount) - .ThrowsAsync(new StripeException("Stripe API error")); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) - .Returns(subscription); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - Exception is caught, error is logged, and traditional email is sent - _logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => - o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") && - o.ToString().Contains(parsedEvent.Id)), - Arg.Is(e => e is StripeException), - Arg.Any>()); - - await _mailer.DidNotReceive().SendEmail(Arg.Any()); - - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("user@example.com")), - Arg.Is(amount => amount == invoice.AmountDue / 100M), - Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), - Arg.Is>(items => items.Count == invoice.Lines.Data.Count), - Arg.Is(b => b == true)); - } - [Fact] - public async Task HandleAsync_Premium_DeferEnabled_CallsScheduler() + public async Task HandleAsync_Premium_CallsScheduler() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; @@ -2854,7 +1275,6 @@ public async Task HandleAsync_Premium_DeferEnabled_CallsScheduler() .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount) .Returns(new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount }); @@ -2868,7 +1288,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( } [Fact] - public async Task HandleAsync_Families_DeferEnabled_CallsScheduler() + public async Task HandleAsync_Families_CallsScheduler() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; @@ -2917,7 +1337,6 @@ public async Task HandleAsync_Families_DeferEnabled_CallsScheduler() _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(new FamiliesPlan()); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount) .Returns(new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }); @@ -2932,7 +1351,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( } [Fact] - public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings() + public async Task HandleAsync_WhenOrganizationTaxNotEnabled_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; @@ -2963,7 +1382,6 @@ public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_SchedulePrese .Returns(new Tuple(_organizationId, null, null)); _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new TeamsPlan(isAnnual: true)); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList @@ -3019,7 +1437,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( } [Fact] - public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_SchedulePresent_CarriesCustomerDiscountIntoFuturePhaseOnly() + public async Task HandleAsync_WhenOrganizationTaxNotEnabled_SchedulePresent_CarriesCustomerDiscountIntoFuturePhaseOnly() { // C1 (worker): carry the customer discount into the FUTURE phase only (StartDate > now), not // the active phase 0 — whose discountConsumed predicate is false but which is still billing. @@ -3057,7 +1475,6 @@ public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_SchedulePrese .Returns(new Tuple(_organizationId, null, null)); _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new TeamsPlan(isAnnual: true)); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList @@ -3106,7 +1523,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( } [Fact] - public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_NoSchedule_UpdatesSubscriptionDirectly() + public async Task HandleAsync_WhenOrganizationTaxNotEnabled_NoSchedule_UpdatesSubscriptionDirectly() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; @@ -3133,7 +1550,6 @@ public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_NoSchedule_Up .Returns(new Tuple(_organizationId, null, null)); _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new TeamsPlan(isAnnual: true)); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { Data = new List() }); @@ -3152,7 +1568,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync( } [Fact] - public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings() + public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; @@ -3182,7 +1598,6 @@ public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_SchedulePresen _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList @@ -3238,7 +1653,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( } [Fact] - public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_NoSchedule_UpdatesSubscriptionDirectly() + public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_NoSchedule_UpdatesSubscriptionDirectly() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; @@ -3264,7 +1679,6 @@ public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_NoSchedule_Upd _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { Data = new List() }); @@ -3283,7 +1697,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync( } [Fact] - public async Task HandleAsync_WhenTaxNotEnabled_FlagOn_Phase2Active_SkipsCompletedPhaseAndClearsConsumedDiscounts() + public async Task HandleAsync_WhenTaxNotEnabled_Phase2Active_SkipsCompletedPhaseAndClearsConsumedDiscounts() { // Arrange — Phase 1 has ended, Phase 2 is now the active phase. // Phase 2's one-time migration discount was consumed at transition and must not be re-included. @@ -3315,7 +1729,6 @@ public async Task HandleAsync_WhenTaxNotEnabled_FlagOn_Phase2Active_SkipsComplet _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs index dde307b98bbd..210e57ded769 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs @@ -246,7 +246,7 @@ public async Task DeleteManyUsersAsync_WithMixedValidationResults_HandlesPartial [Theory] [BitAutoData] - public async Task DeleteManyUsersAsync_CancelPremiumsAsync_FlagEnabled_CallsSubscriberService( + public async Task DeleteManyUsersAsync_CancelPremiumsAsync_CallsSubscriberService( SutProvider sutProvider, User user, Guid organizationId, @@ -274,10 +274,6 @@ public async Task DeleteManyUsersAsync_CancelPremiumsAsync_FlagEnabled_CallsSubs SetupValidatorMock(sutProvider, [CreateSuccessfulValidationResult(request)]); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUser.Id], deletingUserId); Assert.True(results.Single().Result.IsSuccess); @@ -288,58 +284,11 @@ await sutProvider.GetDependency() user, cancelImmediately: false, Arg.Is(r => r.UserId == user.Id)); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CancelPremiumAsync(default); } [Theory] [BitAutoData] - public async Task DeleteManyUsersAsync_CancelPremiumsAsync_FlagDisabled_CallsCancelPremium( - SutProvider sutProvider, - User user, - Guid organizationId, - Guid deletingUserId, - [OrganizationUser] OrganizationUser orgUser) - { - orgUser.UserId = user.Id; - orgUser.OrganizationId = organizationId; - - var request = new DeleteUserValidationRequest - { - OrganizationId = organizationId, - OrganizationUserId = orgUser.Id, - OrganizationUser = orgUser, - User = user, - DeletingUserId = deletingUserId, - IsClaimed = true - }; - - SetupRepositoryMocks(sutProvider, - new List { orgUser }, - [user], - organizationId, - new Dictionary { { orgUser.Id, true } }); - - SetupValidatorMock(sutProvider, [CreateSuccessfulValidationResult(request)]); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(false); - - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUser.Id], deletingUserId); - - Assert.True(results.Single().Result.IsSuccess); - - await sutProvider.GetDependency().Received(1).CancelPremiumAsync(user); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CancelSubscription(default, default, default); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_CancelPremiumsAsync_FlagDisabled_HandlesGatewayException( + public async Task DeleteManyUsersAsync_CancelPremiumsAsync_HandlesGatewayException( SutProvider sutProvider, User user, Guid organizationId, @@ -368,60 +317,6 @@ public async Task DeleteManyUsersAsync_CancelPremiumsAsync_FlagDisabled_HandlesG SetupValidatorMock(sutProvider, [validationResult]); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(false); - - var gatewayException = new GatewayException("Payment gateway error"); - sutProvider.GetDependency() - .CancelPremiumAsync(user) - .ThrowsAsync(gatewayException); - - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUser.Id], deletingUserId); - - var resultsList = results.ToList(); - Assert.Single(resultsList); - Assert.True(resultsList.First().Result.IsSuccess); - - await sutProvider.GetDependency().Received(1).CancelPremiumAsync(user); - await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_CancelPremiumsAsync_FlagEnabled_HandlesGatewayException( - SutProvider sutProvider, - User user, - Guid organizationId, - Guid deletingUserId, - [OrganizationUser] OrganizationUser orgUser) - { - orgUser.UserId = user.Id; - orgUser.OrganizationId = organizationId; - - var request = new DeleteUserValidationRequest - { - OrganizationId = organizationId, - OrganizationUserId = orgUser.Id, - OrganizationUser = orgUser, - User = user, - DeletingUserId = deletingUserId, - IsClaimed = true - }; - var validationResult = CreateSuccessfulValidationResult(request); - - SetupRepositoryMocks(sutProvider, - new List { orgUser }, - [user], - organizationId, - new Dictionary { { orgUser.Id, true } }); - - SetupValidatorMock(sutProvider, [validationResult]); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); - var gatewayException = new GatewayException("Payment gateway error"); sutProvider.GetDependency() .CancelSubscription(user, cancelImmediately: false, Arg.Any()) @@ -439,64 +334,12 @@ await sutProvider.GetDependency() user, cancelImmediately: false, Arg.Is(r => r.UserId == user.Id)); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CancelPremiumAsync(default); - await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]); - } - - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_CancelPremiumsAsync_FlagDisabled_HandlesBillingExceptionAndLogsWarning( - SutProvider sutProvider, - User user, - Guid organizationId, - Guid deletingUserId, - [OrganizationUser] OrganizationUser orgUser) - { - orgUser.UserId = user.Id; - orgUser.OrganizationId = organizationId; - - var request = new DeleteUserValidationRequest - { - OrganizationId = organizationId, - OrganizationUserId = orgUser.Id, - OrganizationUser = orgUser, - User = user, - DeletingUserId = deletingUserId, - IsClaimed = true - }; - var validationResult = CreateSuccessfulValidationResult(request); - - SetupRepositoryMocks(sutProvider, - new List { orgUser }, - [user], - organizationId, - new Dictionary { { orgUser.Id, true } }); - - SetupValidatorMock(sutProvider, [validationResult]); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(false); - - var billingException = new BillingException(); - sutProvider.GetDependency() - .CancelPremiumAsync(user) - .ThrowsAsync(billingException); - - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUser.Id], deletingUserId); - - var resultsList = results.ToList(); - Assert.Single(resultsList); - Assert.True(resultsList.First().Result.IsSuccess); - - await sutProvider.GetDependency().Received(1).CancelPremiumAsync(user); await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]); } [Theory] [BitAutoData] - public async Task DeleteManyUsersAsync_CancelPremiumsAsync_FlagEnabled_HandlesBillingException( + public async Task DeleteManyUsersAsync_CancelPremiumsAsync_HandlesBillingException( SutProvider sutProvider, User user, Guid organizationId, @@ -525,10 +368,6 @@ public async Task DeleteManyUsersAsync_CancelPremiumsAsync_FlagEnabled_HandlesBi SetupValidatorMock(sutProvider, [validationResult]); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); - var billingException = new BillingException(); sutProvider.GetDependency() .CancelSubscription(user, cancelImmediately: false, Arg.Any()) @@ -546,7 +385,6 @@ await sutProvider.GetDependency() user, cancelImmediately: false, Arg.Is(r => r.UserId == user.Id)); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CancelPremiumAsync(default); await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs index c20811691204..d5ab24d4b567 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs @@ -61,63 +61,28 @@ public async Task Delete_Fails_KeyConnector(Organization organization, SutProvid } [Theory, PaidOrganizationCustomize, BitAutoData] - public async Task Delete_WhenFlagEnabled_CallsSubscriberService( + public async Task Delete_CallsSubscriberService( Organization organization, SutProvider sutProvider) { organization.GatewaySubscriptionId = "sub_123"; organization.ExpirationDate = DateTime.UtcNow.AddDays(10); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); - await sutProvider.Sut.DeleteAsync(organization); await sutProvider.GetDependency() .Received(1) .CancelSubscription(organization, cancelImmediately: false); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CancelSubscriptionAsync(default, default); - } - - [Theory, PaidOrganizationCustomize, BitAutoData] - public async Task Delete_WhenFlagDisabled_CallsLegacyPaymentService( - Organization organization, - SutProvider sutProvider) - { - organization.GatewaySubscriptionId = "sub_123"; - organization.ExpirationDate = DateTime.UtcNow.AddDays(10); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(false); - - await sutProvider.Sut.DeleteAsync(organization); - - await sutProvider.GetDependency() - .Received(1) - .CancelSubscriptionAsync(organization, true); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CancelSubscription(default, default, default); } [Theory, PaidOrganizationCustomize, BitAutoData] - public async Task Delete_WhenFlagEnabled_HandlesBillingException( + public async Task Delete_HandlesBillingException( Organization organization, SutProvider sutProvider) { organization.GatewaySubscriptionId = "sub_123"; organization.ExpirationDate = DateTime.UtcNow.AddDays(10); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); - var billingException = new BillingException(); sutProvider.GetDependency() .CancelSubscription(organization, cancelImmediately: false) @@ -129,28 +94,6 @@ public async Task Delete_WhenFlagEnabled_HandlesBillingException( } - [Theory, PaidOrganizationCustomize, BitAutoData] - public async Task Delete_WhenFlagDisabled_HandlesBillingException( - Organization organization, - SutProvider sutProvider) - { - organization.GatewaySubscriptionId = "sub_123"; - organization.ExpirationDate = DateTime.UtcNow.AddDays(10); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(false); - - var billingException = new BillingException(); - sutProvider.GetDependency() - .CancelSubscriptionAsync(organization, Arg.Any()) - .ThrowsAsync(billingException); - - await sutProvider.Sut.DeleteAsync(organization); - - await sutProvider.GetDependency().Received(1).DeleteAsync(organization); - } - [Theory, PaidOrganizationCustomize, BitAutoData] public async Task Delete_WithFileSends_DeletesFilesBeforeDbRecords( Organization organization, diff --git a/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs index d9c8b8f350df..375b3023db1d 100644 --- a/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs @@ -29,6 +29,9 @@ public UpdateBillingAddressCommandTests() Substitute.For>(), _subscriberService, _stripeAdapter); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = new List() }); } [Fact] @@ -696,7 +699,7 @@ await _stripeAdapter.Received(1).UpdateCustomerAsync(organization.GatewayCustome } [Fact] - public async Task Run_PersonalOrganization_FlagOn_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings() + public async Task Run_PersonalOrganization_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings() { var organization = new Organization { @@ -740,8 +743,6 @@ public async Task Run_PersonalOrganization_FlagOn_SchedulePresent_UpdatesSchedul options.HasExpansions("subscriptions", "subscriptions.data.test_clock") )).Returns(customer); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { @@ -795,7 +796,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( } [Fact] - public async Task Run_PersonalOrganization_FlagOn_SchedulePresent_CarriesCustomerDiscountIntoFuturePhaseOnly() + public async Task Run_PersonalOrganization_SchedulePresent_CarriesCustomerDiscountIntoFuturePhaseOnly() { // C1: carry the customer discount into the FUTURE phase (StartDate > now) only — not the // active phase 0, even though its discountConsumed predicate is false. @@ -843,8 +844,6 @@ public async Task Run_PersonalOrganization_FlagOn_SchedulePresent_CarriesCustome options.HasExpansions("subscriptions", "subscriptions.data.test_clock") )).Returns(customer); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { @@ -897,7 +896,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( } [Fact] - public async Task Run_PersonalOrganization_FlagOn_Phase2Consumed_DiscountsSuppressed_CustomerCouponNotReAdded() + public async Task Run_PersonalOrganization_Phase2Consumed_DiscountsSuppressed_CustomerCouponNotReAdded() { // When phase 1 has ended, phase 2 is active and its discounts are consumed → suppressed to []. // The customer coupon must NOT be re-added to the consumed phase. @@ -943,8 +942,6 @@ public async Task Run_PersonalOrganization_FlagOn_Phase2Consumed_DiscountsSuppre _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Any()) .Returns(customer); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { @@ -993,7 +990,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( } [Fact] - public async Task Run_PersonalOrganization_FlagOn_NoSchedule_UpdatesSubscriptionDirectly() + public async Task Run_PersonalOrganization_NoSchedule_UpdatesSubscriptionDirectly() { var organization = new Organization { @@ -1033,8 +1030,6 @@ public async Task Run_PersonalOrganization_FlagOn_NoSchedule_UpdatesSubscription options.HasExpansions("subscriptions", "subscriptions.data.test_clock") )).Returns(customer); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { Data = new List() }); diff --git a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs index 9bea59e70b7e..1c3fe60719c0 100644 --- a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs +++ b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs @@ -35,24 +35,9 @@ public class PriceIncreaseSchedulerTests private PriceIncreaseScheduler CreateSut() => new(_stripeAdapter, _featureService, _pricingClient, _organizationRepository, _assignmentRepository, _cohortRepository, _logger); - [Fact] - public async Task SchedulePersonalPriceIncrease_FeatureFlagOff_DoesNothing() - { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); - - var sut = CreateSut(); - - await sut.SchedulePersonalPriceIncrease(CreateSubscription("sub_1", "cus_1")); - - await _stripeAdapter.DidNotReceiveWithAnyArgs() - .ListSubscriptionSchedulesAsync(Arg.Any()); - } - [Fact] public async Task SchedulePersonalPriceIncrease_ActiveScheduleAlreadyExists_Skips() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - var subscription = CreateSubscription("sub_1", "cus_1"); _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) @@ -72,8 +57,6 @@ await _stripeAdapter.DidNotReceiveWithAnyArgs() [Fact] public async Task SchedulePersonalPriceIncrease_PremiumSubscription_CreatesScheduleWithMilestone2Discount() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - var oldPremium = new PremiumPlan { Name = "Premium (Old)", @@ -121,8 +104,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_PremiumSubscriptionWithExistingDiscount_PreservesDiscountAndAppendsMilestone2() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - var oldPremium = new PremiumPlan { Name = "Premium (Old)", @@ -170,8 +151,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_PremiumSubscriptionWithMultipleExistingDiscounts_PreservesAllAndAppendsMilestone2() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - var oldPremium = new PremiumPlan { Name = "Premium (Old)", @@ -221,8 +200,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_Premium_CarriesCustomerDiscountIntoPhase2_WithMilestone2() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - var oldPremium = new PremiumPlan { Name = "Premium (Old)", @@ -272,8 +249,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_PremiumSubscriptionWithStorage_IncludesStorageInPhase2() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - var oldPremium = new PremiumPlan { Name = "Premium (Old)", @@ -317,8 +292,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_Families2019Subscription_CreatesScheduleWithMilestone3Discount() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - // Return empty premium plans so it falls through to families logic _pricingClient.ListPremiumPlans().Returns([]); @@ -360,8 +333,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_Families2019SubscriptionWithExistingDiscount_PreservesDiscountAndAppendsMilestone3() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _pricingClient.ListPremiumPlans().Returns([]); var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); @@ -402,8 +373,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_Families2019_CarriesCustomerDiscountIntoPhase2_WithMilestone3() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _pricingClient.ListPremiumPlans().Returns([]); var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); @@ -445,8 +414,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_Families2025_CarriesCustomerDiscountIntoPhase2_NoMilestone() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _pricingClient.ListPremiumPlans().Returns([]); var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); @@ -489,8 +456,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_Families2025Subscription_CreatesScheduleWithNoDiscount() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _pricingClient.ListPremiumPlans().Returns([]); var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); @@ -530,8 +495,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_Families2025SubscriptionWithExistingDiscount_PreservesDiscountWithoutMilestone() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _pricingClient.ListPremiumPlans().Returns([]); var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); @@ -572,8 +535,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_FamiliesSubscriptionWithStorage_IncludesStorageInPhase2() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _pricingClient.ListPremiumPlans().Returns([]); var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); @@ -610,8 +571,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( [Fact] public async Task SchedulePersonalPriceIncrease_UpdateFails_ReleasesOrphanedScheduleAndRethrows() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _pricingClient.ListPremiumPlans().Returns([]); var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); @@ -647,8 +606,6 @@ public async Task SchedulePersonalPriceIncrease_UpdateFails_ReleasesOrphanedSche [Fact] public async Task SchedulePersonalPriceIncrease_NoMatchingPlan_LogsWarningAndDoesNothing() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _pricingClient.ListPremiumPlans().Returns([]); var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); @@ -678,8 +635,6 @@ await _stripeAdapter.DidNotReceiveWithAnyArgs() [Fact] public async Task SchedulePersonalPriceIncrease_SubscriptionLoadedWithoutDiscountsExpand_DoesNotCreateSchedule() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - // Construct the subscription via the same JSON path Stripe.NET uses on API responses. // Verified empirically against Stripe.net 48.5.0: when "discounts" is not in the request's Expand list, // the SDK populates DiscountIds with the IDs and Discounts with a same-length list of null entries. @@ -712,8 +667,6 @@ await _stripeAdapter.DidNotReceiveWithAnyArgs() [Fact] public async Task SchedulePersonalPriceIncrease_ProviderSubscription_DoesNotCreateSchedule() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); @@ -730,9 +683,8 @@ await _stripeAdapter.DidNotReceiveWithAnyArgs() } [Fact] - public async Task Release_BothFeatureFlagsOff_StillReleasesWhenScheduleExists() + public async Task Release_PM35215FlagOff_StillReleasesWhenScheduleExists() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) @@ -751,7 +703,6 @@ public async Task Release_BothFeatureFlagsOff_StillReleasesWhenScheduleExists() [Fact] public async Task Release_PM35215EnabledOnly_StillReleases() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) @@ -770,8 +721,6 @@ public async Task Release_PM35215EnabledOnly_StillReleases() [Fact] public async Task Release_ActiveScheduleExists_ReleasesIt() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { @@ -788,8 +737,6 @@ public async Task Release_ActiveScheduleExists_ReleasesIt() [Fact] public async Task Release_NoActiveSchedule_DoesNotRelease() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); @@ -804,8 +751,6 @@ await _stripeAdapter.DidNotReceiveWithAnyArgs() [Fact] public async Task Release_ScheduleForDifferentSubscription_DoesNotRelease() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { @@ -823,8 +768,6 @@ await _stripeAdapter.DidNotReceiveWithAnyArgs() [Fact] public async Task Release_ReleaseThrows_LogsErrorAndRethrows() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .ThrowsAsync(new StripeException("list failed")); @@ -1173,8 +1116,6 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( [Fact] public async Task SchedulePersonalPriceIncrease_DoesNotSetMetadataOnPhases() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - var oldPremium = new PremiumPlan { Name = "Premium (Old)", @@ -1988,8 +1929,6 @@ await _assignmentRepository.DidNotReceiveWithAnyArgs() [Fact] public async Task ScheduleForSubscription_UserSubscription_RoutesToPersonalPath_CreatesSchedule() { - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - var oldPremium = new PremiumPlan { Name = "Premium (Old)", @@ -2415,8 +2354,6 @@ await _stripeAdapter.DidNotReceiveWithAnyArgs() [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); diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index f75bb2f9a6be..a5c9f3c5dd92 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -171,7 +171,7 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_UpdateSubscriptionToCan .Returns(subscription); var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); var offboardingSurveyResponse = new OffboardingSurveyResponse { @@ -246,7 +246,7 @@ public async Task CancelSubscription_NullSurvey_CancelAtEndOfPeriod_SetsCancelAt stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false); @@ -263,7 +263,7 @@ await stripeAdapter } [Theory, BitAutoData] - public async Task CancelSubscription_CancelImmediately_FlagOn_WithActiveSchedule_ReleasesScheduleBeforeCancelling( + public async Task CancelSubscription_CancelImmediately_PM35215FlagOn_WithActiveSchedule_ReleasesScheduleBeforeCancelling( Organization organization, SutProvider sutProvider) { @@ -294,7 +294,7 @@ public async Task CancelSubscription_CancelImmediately_FlagOn_WithActiveSchedule }); var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); @@ -304,7 +304,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task CancelSubscription_CancelImmediately_BothFlagsOff_DoesNotCheckOrReleaseSchedule( + public async Task CancelSubscription_CancelImmediately_PM35215FlagOff_DoesNotCheckOrReleaseSchedule( Organization organization, SutProvider sutProvider) { @@ -321,7 +321,6 @@ public async Task CancelSubscription_CancelImmediately_BothFlagsOff_DoesNotCheck stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); 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); @@ -366,7 +365,6 @@ public async Task CancelSubscription_CancelImmediately_PM35215FlagOn_WithActiveS }); 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); @@ -422,7 +420,7 @@ await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, } [Theory, BitAutoData] - public async Task CancelSubscription_CancelImmediately_FlagOn_NoSchedule_ProceedsNormally( + public async Task CancelSubscription_CancelImmediately_PM35215FlagOn_NoSchedule_ProceedsNormally( Organization organization, SutProvider sutProvider) { @@ -441,7 +439,7 @@ public async Task CancelSubscription_CancelImmediately_FlagOn_NoSchedule_Proceed .Returns(new StripeList { Data = [] }); var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); @@ -451,7 +449,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_ReleasesScheduleAndSetsCancelAtPeriodEnd( + public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOn_TwoPhaseSchedule_ReleasesScheduleAndSetsCancelAtPeriodEnd( Organization organization, SutProvider sutProvider) { @@ -498,7 +496,7 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule }); var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false); @@ -515,7 +513,7 @@ await stripeAdapter.DidNotReceiveWithAnyArgs() } [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_WithSurvey_ReleasesScheduleAndUpdatesCancellationDetails( + public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOn_TwoPhaseSchedule_WithSurvey_ReleasesScheduleAndUpdatesCancellationDetails( Organization organization, SutProvider sutProvider) { @@ -563,7 +561,7 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule }); var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); var offboardingSurveyResponse = new OffboardingSurveyResponse { @@ -590,7 +588,7 @@ await stripeAdapter.DidNotReceiveWithAnyArgs() } [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_NoSchedule_SetsCancelAtPeriodEnd( + public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOn_NoSchedule_SetsCancelAtPeriodEnd( Organization organization, SutProvider sutProvider) { @@ -609,7 +607,7 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_NoSchedule_SetsC .Returns(new StripeList { Data = [] }); var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false); @@ -621,7 +619,7 @@ await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, } [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOff_SetsCancelAtPeriodEnd_NoScheduleCheck( + public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOff_SetsCancelAtPeriodEnd_NoScheduleCheck( Organization organization, SutProvider sutProvider) { @@ -638,7 +636,7 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOff_SetsCancelAtPer stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false); diff --git a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs index 5cf5d7066420..690adf003ad9 100644 --- a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs @@ -40,7 +40,7 @@ public async Task Run_SubscriptionNotPendingCancellation_ReturnsBadRequest() } [Fact] - public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOff_FallsThroughToStandardReinstate_NoScheduleCheck() + public async Task Run_BusinessPlanPriceMigrationFlagOff_FallsThroughToStandardReinstate_NoScheduleCheck() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -52,7 +52,7 @@ public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOff_FallsThroughTo CancelAt = DateTime.UtcNow.AddDays(30) }); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); var result = await _command.Run(user); @@ -64,7 +64,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", } [Fact] - public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOn_NoSchedule_FallsThroughToStandardReinstate() + public async Task Run_BusinessPlanPriceMigrationFlagOn_NoSchedule_FallsThroughToStandardReinstate() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -79,7 +79,7 @@ public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOn_NoSchedule_Fall Items = new StripeList { Data = [] } }); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); var result = await _command.Run(user); @@ -91,7 +91,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", } [Fact] - public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() + public async Task Run_BusinessPlanPriceMigrationFlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -110,7 +110,7 @@ public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOn_NoSchedule_Canc Items = new StripeList { Data = [] } }); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); var result = await _command.Run(user); @@ -123,7 +123,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", } [Fact] - public async Task Run_BusinessPlanPriceMigrationFlagOn_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() + public async Task Run_BusinessPlanPriceMigrationFlagOn_Organization_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() { var organizationId = Guid.NewGuid(); var organization = new Organization { Id = organizationId, GatewaySubscriptionId = "sub_1" }; @@ -143,7 +143,6 @@ public async Task Run_BusinessPlanPriceMigrationFlagOn_CancelledDuringDeferredPr Items = new StripeList { Data = [] } }); - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); var result = await _command.Run(organization); diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index af2544ee2443..8f9cf9e1365e 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -508,21 +508,7 @@ public async Task AdminResetPasswordAsync_EmptyOrWhitespaceResetPasswordKey_Thro } [Theory, BitAutoData] - public async Task CancelPremiumAsync_CallsPaymentService( - User user, - SutProvider sutProvider) - { - user.PremiumExpirationDate = DateTime.UtcNow.AddDays(30); - - await sutProvider.Sut.CancelPremiumAsync(user); - - await sutProvider.GetDependency() - .Received(1) - .CancelSubscriptionAsync(user, true); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_FlagEnabled_WithGatewaySubscription_CallsSubscriberService( + public async Task DeleteAsync_WithGatewaySubscription_CallsSubscriberService( User user, SutProvider sutProvider) { @@ -536,10 +522,6 @@ public async Task DeleteAsync_FlagEnabled_WithGatewaySubscription_CallsSubscribe .GetCountByOnlyOwnerAsync(user.Id) .Returns(0); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); - var result = await sutProvider.Sut.DeleteAsync(user); Assert.True(result.Succeeded); @@ -550,43 +532,6 @@ await sutProvider.GetDependency() user, cancelImmediately: false, Arg.Is(r => r.UserId == user.Id)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CancelSubscriptionAsync(default, default); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_FlagDisabled_WithGatewaySubscription_CallsCancelPremium( - User user, - SutProvider sutProvider) - { - user.GatewaySubscriptionId = "sub_test"; - user.PremiumExpirationDate = DateTime.UtcNow.AddDays(30); - - sutProvider.GetDependency() - .GetCountByOnlyOwnerAsync(user.Id) - .Returns(0); - - sutProvider.GetDependency() - .GetCountByOnlyOwnerAsync(user.Id) - .Returns(0); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(false); - - var result = await sutProvider.Sut.DeleteAsync(user); - - Assert.True(result.Succeeded); - - await sutProvider.GetDependency() - .Received(1) - .CancelSubscriptionAsync(user, true); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CancelSubscription(default, default, default); } [Theory, BitAutoData] From 8b58bd8c4baf640f024e1df9ed99b1b98dc6822f Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Mon, 29 Jun 2026 10:32:05 -0500 Subject: [PATCH 2/4] Remove unused usings orphaned by the flag removal (IDE0005) --- src/Api/Billing/Controllers/OrganizationsController.cs | 1 - .../Organizations/OrganizationDeleteCommand.cs | 1 - .../Billing/Controllers/OrganizationsControllerTests.cs | 1 - test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs | 5 +---- .../Organizations/OrganizationDeleteCommandTests.cs | 1 - 5 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 69e79b36c6a0..0b2d4cd1e60a 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -7,7 +7,6 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs index ebea407403f6..d954b23e5ef6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs @@ -7,7 +7,6 @@ using Bit.Core.Billing.Services; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Tools.Services; using Bit.Core.Vault.Services; using Microsoft.Extensions.Logging; diff --git a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs index 5fc86c559972..25fdeaab9ace 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs @@ -3,7 +3,6 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Controllers; using Bit.Api.Models.Request.Organizations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 381f5b891c86..947277ebda49 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -1,5 +1,4 @@ -using System.Globalization; -using Bit.Billing.Services; +using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Core; using Bit.Core.AdminConsole.Entities; @@ -17,9 +16,7 @@ using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Mail.Billing.Renewal.BusinessPlanRenewal2020Migration; -using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; -using Bit.Core.Models.Mail.Billing.Renewal.Premium; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs index d5ab24d4b567..d96c1b7ccff7 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs @@ -9,7 +9,6 @@ using Bit.Core.Billing.Services; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Tools.Services; using Bit.Core.Vault.Services; From fe25ce39fb8f232cb55b4f309f6f422f75005d9f Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Mon, 29 Jun 2026 13:06:44 -0500 Subject: [PATCH 3/4] Make cancel/reinstate schedule handling unconditional Removing PM32645 had collapsed the PM32645-or-PM35215 schedule gates to PM35215-only, but PM35215 isn't enabled in production, which would drop the schedule release/recreate that PM32645-on currently provides for personal subscribers. Gate on schedule/metadata presence instead (matching PriceIncreaseScheduler.Release, which never checked a flag) and drop the now-unused IFeatureService from both types. --- .../Implementations/SubscriberService.cs | 36 ++--- .../Commands/ReinstateSubscriptionCommand.cs | 37 ++--- .../Services/SubscriberServiceTests.cs | 148 +++--------------- .../ReinstateSubscriptionCommandTests.cs | 42 +---- 4 files changed, 54 insertions(+), 209 deletions(-) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 9bc8ffc1e849..275d373aa63f 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -12,7 +12,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Braintree; @@ -28,7 +27,6 @@ namespace Bit.Core.Billing.Services.Implementations; public class SubscriberService( IBraintreeGateway braintreeGateway, - IFeatureService featureService, IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, @@ -228,13 +226,10 @@ private async Task CancelSubscriptionImmediatelyAsync( SubscriptionCancellationDetailsOptions? cancellationDetails, Dictionary? cancellingUserMetadata) { - if (featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) + var activeSchedule = await GetActiveScheduleAsync(subscription); + if (activeSchedule != null) { - var activeSchedule = await GetActiveScheduleAsync(subscription); - if (activeSchedule != null) - { - await priceIncreaseScheduler.Release(subscription.CustomerId, subscription.Id); - } + await priceIncreaseScheduler.Release(subscription.CustomerId, subscription.Id); } if (cancellingUserMetadata != null && subscription.Metadata.ContainsKey(MetadataKeys.OrganizationId)) @@ -263,23 +258,20 @@ private async Task CancelSubscriptionAtPeriodEndAsync( Metadata = cancellingUserMetadata }; - if (featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) - { - var activeSchedule = await GetActiveScheduleAsync(subscription); + var activeSchedule = await GetActiveScheduleAsync(subscription); - if (activeSchedule is { Phases.Count: > 0 }) - { - logger.LogInformation( - "{Service}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), releasing schedule before cancellation", - GetType().Name, activeSchedule.Id, subscription.Id); + if (activeSchedule is { Phases.Count: > 0 }) + { + logger.LogInformation( + "{Service}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), releasing schedule before cancellation", + GetType().Name, activeSchedule.Id, subscription.Id); - await stripeAdapter.ReleaseSubscriptionScheduleAsync(activeSchedule.Id); + await stripeAdapter.ReleaseSubscriptionScheduleAsync(activeSchedule.Id); - updateOptions.Metadata = new Dictionary(cancellingUserMetadata ?? []) - { - [MetadataKeys.CancelledDuringDeferredPriceIncrease] = "true" - }; - } + updateOptions.Metadata = new Dictionary(cancellingUserMetadata ?? []) + { + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = "true" + }; } await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, updateOptions); diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs index 05abb8df7d08..7044b49dd42d 100644 --- a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -3,7 +3,6 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; -using Bit.Core.Services; using Microsoft.Extensions.Logging; using OneOf.Types; using Stripe; @@ -20,7 +19,6 @@ public interface IReinstateSubscriptionCommand public class ReinstateSubscriptionCommand( ILogger logger, IStripeAdapter stripeAdapter, - IFeatureService featureService, IPriceIncreaseScheduler priceIncreaseScheduler) : BaseBillingCommand(logger), IReinstateSubscriptionCommand { private readonly ILogger _logger = logger; @@ -41,30 +39,27 @@ public Task> Run(ISubscriber subscriber) => HandleAsy return new BadRequest("Subscription is not pending cancellation."); } - if (featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) + if (subscription.Metadata?.ContainsKey(MetadataKeys.CancelledDuringDeferredPriceIncrease) == true) { - if (subscription.Metadata?.ContainsKey(MetadataKeys.CancelledDuringDeferredPriceIncrease) == true) - { - _logger.LogInformation( - "{Command}: Subscription ({SubscriptionId}) has pending price increase, clearing flag and recreating schedule", - CommandName, subscription.Id); + _logger.LogInformation( + "{Command}: Subscription ({SubscriptionId}) has pending price increase, clearing flag and recreating schedule", + CommandName, subscription.Id); - // 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 + // 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 { - CancelAtPeriodEnd = false, - Metadata = new Dictionary - { - [MetadataKeys.CancelledDuringDeferredPriceIncrease] = string.Empty, - [MetadataKeys.CancellingUserId] = string.Empty - } - }); + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = string.Empty, + [MetadataKeys.CancellingUserId] = string.Empty + } + }); - await priceIncreaseScheduler.ScheduleForSubscription(subscription); + await priceIncreaseScheduler.ScheduleForSubscription(subscription); - return new None(); - } + return new None(); } // The default behavior for non-price-migration subscriptions or subscriptions without diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index a5c9f3c5dd92..4828e656f66b 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -6,7 +6,6 @@ using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Enums; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Braintree; @@ -81,6 +80,9 @@ public async Task CancelSubscription_CancelImmediately_BelongsToOrganization_Upd .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); + stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + var offboardingSurveyResponse = new OffboardingSurveyResponse { UserId = userId, @@ -127,6 +129,9 @@ public async Task CancelSubscription_CancelImmediately_BelongsToUser_CancelSubsc .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); + stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + var offboardingSurveyResponse = new OffboardingSurveyResponse { UserId = userId, @@ -170,8 +175,8 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_UpdateSubscriptionToCan .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); - var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); + stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); var offboardingSurveyResponse = new OffboardingSurveyResponse { @@ -214,6 +219,8 @@ public async Task CancelSubscription_NullSurvey_CancelImmediately_CancelsWithout var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); + stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); @@ -244,9 +251,8 @@ public async Task CancelSubscription_NullSurvey_CancelAtEndOfPeriod_SetsCancelAt var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); - - var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); + stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false); @@ -263,7 +269,7 @@ await stripeAdapter } [Theory, BitAutoData] - public async Task CancelSubscription_CancelImmediately_PM35215FlagOn_WithActiveSchedule_ReleasesScheduleBeforeCancelling( + public async Task CancelSubscription_CancelImmediately_WithActiveSchedule_ReleasesScheduleBeforeCancelling( Organization organization, SutProvider sutProvider) { @@ -293,80 +299,6 @@ public async Task CancelSubscription_CancelImmediately_PM35215FlagOn_WithActiveS ] }); - var featureService = sutProvider.GetDependency(); - 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_CancelImmediately_PM35215FlagOff_DoesNotCheckOrReleaseSchedule( - Organization organization, - SutProvider sutProvider) - { - const string subscriptionId = "sub_1"; - - var subscription = new Subscription - { - Id = subscriptionId, - Status = "active", - CustomerId = "cus_1" - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); - - var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); - - await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); - - await stripeAdapter.DidNotReceiveWithAnyArgs() - .ListSubscriptionSchedulesAsync(Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().Release(Arg.Any(), Arg.Any()); - 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.PM35215_BusinessPlanPriceMigration).Returns(true); - await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); await sutProvider.GetDependency() @@ -375,7 +307,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task CancelSubscription_PM35215FlagOn_PreservesMigrationCohortMetadataOnCancel( + public async Task CancelSubscription_PreservesMigrationCohortMetadataOnCancel( Organization organization, SutProvider sutProvider) { @@ -401,9 +333,6 @@ public async Task CancelSubscription_PM35215FlagOn_PreservesMigrationCohortMetad 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(), @@ -420,7 +349,7 @@ await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, } [Theory, BitAutoData] - public async Task CancelSubscription_CancelImmediately_PM35215FlagOn_NoSchedule_ProceedsNormally( + public async Task CancelSubscription_CancelImmediately_NoSchedule_ProceedsNormally( Organization organization, SutProvider sutProvider) { @@ -438,9 +367,6 @@ public async Task CancelSubscription_CancelImmediately_PM35215FlagOn_NoSchedule_ stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); - var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); - await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); await sutProvider.GetDependency() @@ -449,7 +375,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOn_TwoPhaseSchedule_ReleasesScheduleAndSetsCancelAtPeriodEnd( + public async Task CancelSubscription_CancelAtEndOfPeriod_TwoPhaseSchedule_ReleasesScheduleAndSetsCancelAtPeriodEnd( Organization organization, SutProvider sutProvider) { @@ -495,9 +421,6 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOn_TwoPhaseS ] }); - var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); - await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false); await stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync(scheduleId); @@ -513,7 +436,7 @@ await stripeAdapter.DidNotReceiveWithAnyArgs() } [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOn_TwoPhaseSchedule_WithSurvey_ReleasesScheduleAndUpdatesCancellationDetails( + public async Task CancelSubscription_CancelAtEndOfPeriod_TwoPhaseSchedule_WithSurvey_ReleasesScheduleAndUpdatesCancellationDetails( Organization organization, SutProvider sutProvider) { @@ -560,9 +483,6 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOn_TwoPhaseS ] }); - var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); - var offboardingSurveyResponse = new OffboardingSurveyResponse { UserId = userId, @@ -588,7 +508,7 @@ await stripeAdapter.DidNotReceiveWithAnyArgs() } [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOn_NoSchedule_SetsCancelAtPeriodEnd( + public async Task CancelSubscription_CancelAtEndOfPeriod_NoSchedule_SetsCancelAtPeriodEnd( Organization organization, SutProvider sutProvider) { @@ -606,9 +526,6 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOn_NoSchedul stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); - var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); - await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false); await stripeAdapter.DidNotReceiveWithAnyArgs() @@ -618,35 +535,6 @@ await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, Arg.Is(o => o.CancelAtPeriodEnd == true)); } - [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_PM35215FlagOff_SetsCancelAtPeriodEnd_NoScheduleCheck( - Organization organization, - SutProvider sutProvider) - { - const string subscriptionId = "sub_1"; - - var subscription = new Subscription - { - Id = subscriptionId, - Status = "active", - CustomerId = "cus_1" - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); - - var featureService = sutProvider.GetDependency(); - featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); - - await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false); - - await stripeAdapter.DidNotReceiveWithAnyArgs() - .ListSubscriptionSchedulesAsync(Arg.Any()); - - await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, - Arg.Is(o => o.CancelAtPeriodEnd == true)); - } - #endregion #region GetCustomer diff --git a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs index 690adf003ad9..53dc83f5fbb8 100644 --- a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs @@ -3,7 +3,6 @@ using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Entities; -using Bit.Core.Services; using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; @@ -14,7 +13,6 @@ namespace Bit.Core.Test.Billing.Subscriptions.Commands; public class ReinstateSubscriptionCommandTests { - private readonly IFeatureService _featureService = Substitute.For(); private readonly IPriceIncreaseScheduler _priceIncreaseScheduler = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); @@ -22,7 +20,7 @@ public class ReinstateSubscriptionCommandTests public ReinstateSubscriptionCommandTests() { - _command = new ReinstateSubscriptionCommand(_logger, _stripeAdapter, _featureService, _priceIncreaseScheduler); + _command = new ReinstateSubscriptionCommand(_logger, _stripeAdapter, _priceIncreaseScheduler); } [Fact] @@ -40,31 +38,7 @@ public async Task Run_SubscriptionNotPendingCancellation_ReturnsBadRequest() } [Fact] - public async Task Run_BusinessPlanPriceMigrationFlagOff_FallsThroughToStandardReinstate_NoScheduleCheck() - { - var user = new User { GatewaySubscriptionId = "sub_1" }; - - _stripeAdapter.GetSubscriptionAsync("sub_1", Arg.Any()) - .Returns(new Subscription - { - Id = "sub_1", - Status = SubscriptionStatus.Active, - CancelAt = DateTime.UtcNow.AddDays(30) - }); - - _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); - - var result = await _command.Run(user); - - Assert.True(result.Success); - await _stripeAdapter.DidNotReceiveWithAnyArgs() - .ListSubscriptionSchedulesAsync(Arg.Any()); - await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", - Arg.Is(o => o.CancelAtPeriodEnd == false)); - } - - [Fact] - public async Task Run_BusinessPlanPriceMigrationFlagOn_NoSchedule_FallsThroughToStandardReinstate() + public async Task Run_NoCancelledDuringDeferredPriceIncreaseMetadata_FallsThroughToStandardReinstate() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -79,19 +53,19 @@ public async Task Run_BusinessPlanPriceMigrationFlagOn_NoSchedule_FallsThroughTo Items = new StripeList { Data = [] } }); - _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); - var result = await _command.Run(user); Assert.True(result.Success); await _stripeAdapter.DidNotReceiveWithAnyArgs() .UpdateSubscriptionScheduleAsync(Arg.Any(), Arg.Any()); + await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs() + .ScheduleForSubscription(Arg.Any()); await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", Arg.Is(o => o.CancelAtPeriodEnd == false)); } [Fact] - public async Task Run_BusinessPlanPriceMigrationFlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() + public async Task Run_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -110,8 +84,6 @@ public async Task Run_BusinessPlanPriceMigrationFlagOn_NoSchedule_CancelledDurin Items = new StripeList { Data = [] } }); - _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); - var result = await _command.Run(user); Assert.True(result.Success); @@ -123,7 +95,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", } [Fact] - public async Task Run_BusinessPlanPriceMigrationFlagOn_Organization_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() + public async Task Run_Organization_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() { var organizationId = Guid.NewGuid(); var organization = new Organization { Id = organizationId, GatewaySubscriptionId = "sub_1" }; @@ -143,8 +115,6 @@ public async Task Run_BusinessPlanPriceMigrationFlagOn_Organization_CancelledDur Items = new StripeList { Data = [] } }); - _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); - var result = await _command.Run(organization); Assert.True(result.Success); From 496e464915a7db7ff24598d6330b5310ac0b3791 Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Mon, 29 Jun 2026 14:09:15 -0500 Subject: [PATCH 4/4] Explain why upcoming-invoice handlers return true when scheduling is skipped --- .../Services/Implementations/UpcomingInvoiceHandler.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 04f352907fa4..c3e7880a62cc 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -270,6 +270,10 @@ private async Task ScheduleFamiliesPriceMigrationAsync( try { var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); + + // A false result means no new schedule was created — typically because an earlier upcoming-invoice + // event for this renewal already deferred the migration and sent its renewal email. Return true so + // the caller skips the generic upcoming-invoice email instead of sending a duplicate. if (!scheduled) { return true; @@ -696,6 +700,10 @@ private async Task AlignPremiumUsersSubscriptionConcernsAsync( try { var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); + + // A false result means no new schedule was created — typically because an earlier upcoming-invoice + // event for this renewal already deferred the migration and sent its renewal email. Return true so + // the caller skips the generic upcoming-invoice email instead of sending a duplicate. if (!scheduled) { return true;