From 8d30fbcc8abe424e604073e721ceaeb365c4fba1 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 16 Jan 2026 18:13:57 -0500 Subject: [PATCH] Billing/pm 30882/defect pm coupon removed on upgrade (#6863) * fix(billing): update coupon check logic * tests(billing): update tests and add plan check test --- .../SubscriptionUpdatedHandler.cs | 11 ++- .../SubscriptionUpdatedHandlerTests.cs | 89 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index c10368d8c0..9e20bd3191 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -275,17 +275,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler .PreviousAttributes .ToObject() as Subscription; + // Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the + // previous and/or current subscriptions. + var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans()) + .Where(orgPlan => orgPlan.SupportsSecretsManager && orgPlan.SecretsManager.StripeSeatPlanId != null) + .Select(orgPlan => orgPlan.SecretsManager.StripeSeatPlanId) + .ToHashSet(); + // This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager. // If there are changes to any subscription item, Stripe sends every item in the subscription, both // changed and unchanged. var previousSubscriptionHasSecretsManager = previousSubscription?.Items is not null && previousSubscription.Items.Any( - previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); + previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id)); var currentSubscriptionHasSecretsManager = subscription.Items.Any( - currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); + currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id)); if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager) { diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 182f09e163..2259d846b7 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.Billing.Mocks; using Bit.Core.Test.Billing.Mocks.Plans; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; @@ -654,6 +655,8 @@ public class SubscriptionUpdatedHandlerTests var plan = new Enterprise2023Plan(true); _pricingClient.GetPlanOrThrow(organization.PlanType) .Returns(plan); + _pricingClient.ListPlans() + .Returns(MockPlans.Plans); var parsedEvent = new Event { @@ -693,6 +696,92 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade.Received(1).DeleteCustomerDiscount(subscription.CustomerId); await _stripeFacade.Received(1).DeleteSubscriptionDiscount(subscription.Id); } + [Fact] + public async Task + HandleAsync_WhenUpgradingPlan_AndPreviousPlanHasSecretsManagerTrial_AndCurrentPlanHasSecretsManagerTrial_DoesNotRemovePasswordManagerCoupon() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Id = "sub_123", + Status = StripeSubscriptionStatus.Active, + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + }, + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } + } + ] + }, + Customer = new Customer + { + Balance = 0, + Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } } + }, + Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }], + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } + }; + + // Note: The organization plan is still the previous plan because the subscription is updated before the organization is updated + var organization = new Organization { Id = organizationId, PlanType = PlanType.TeamsAnnually2023 }; + + var plan = new Teams2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType) + .Returns(plan); + _pricingClient.ListPlans() + .Returns(MockPlans.Plans); + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(new + { + items = new + { + data = new[] + { + new { plan = new { id = "secrets-manager-teams-seat-annually" } }, + } + }, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } }, + ] + } + }) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.DidNotReceive().DeleteCustomerDiscount(subscription.CustomerId); + await _stripeFacade.DidNotReceive().DeleteSubscriptionDiscount(subscription.Id); + } [Theory] [MemberData(nameof(GetNonActiveSubscriptions))]