diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index c10368d8c0..96770dfa11 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -141,6 +141,9 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler { await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); } + + // Check if trial just ended and cleanup premium upgrade metadata + await CleanupPremiumUpgradeMetadataAfterTrialAsync(parsedEvent, subscription); break; } case StripeSubscriptionStatus.Active when providerId.HasValue: @@ -205,9 +208,23 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private async Task IsPremiumSubscriptionAsync(Subscription subscription) { + if (subscription.Items == null || !subscription.Items.Any()) + { + return false; + } + var premiumPlans = await _pricingClient.ListPremiumPlans(); - var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet(); - return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id)); + if (premiumPlans == null || !premiumPlans.Any()) + { + return false; + } + + var premiumPriceIds = premiumPlans + .Where(p => p.Seat != null && p.Storage != null) + .SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }) + .ToHashSet(); + + return subscription.Items.Any(i => i.Price != null && premiumPriceIds.Contains(i.Price.Id)); } /// @@ -392,4 +409,80 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } } } + + /// + /// Cleans up premium upgrade metadata after a trial period ends successfully. + /// When a Premium-to-Organization upgrade trial ends and converts to active, + /// the upgrade is finalized and the reversion metadata is no longer needed. + /// + /// The Stripe event containing previous subscription state + /// The current subscription state + private async Task CleanupPremiumUpgradeMetadataAfterTrialAsync(Event parsedEvent, Subscription subscription) + { + // STEP 1: Fastest check first - check if subscription has premium upgrade metadata + // This avoids expensive deserialization for subscriptions without this metadata + if (subscription.Metadata == null || + !subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId)) + { + return; + } + + // STEP 2: Check if PreviousAttributes exists (cheap check) + if (parsedEvent.Data.PreviousAttributes == null) + { + return; + } + + // STEP 3: Expensive operation - deserialize PreviousAttributes (only if metadata exists) + var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject() as Subscription; + + // STEP 4: Verify status transition from trialing to active + if (previousSubscription?.Status != StripeSubscriptionStatus.Trialing || + subscription.Status != StripeSubscriptionStatus.Active) + { + return; + } + + // STEP 5: Check if subscription still has OrganizationId (race condition check) + // If reversion already happened, OrganizationId would be removed and we should skip cleanup + if (!subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId)) + { + _logger.LogInformation( + "Skipping cleanup for subscription {SubscriptionId} - appears to have been reverted already", + subscription.Id); + return; + } + + try + { + _logger.LogInformation( + "Cleaning up premium upgrade metadata for subscription {SubscriptionId} after trial ended", + subscription.Id); + + // Remove all premium upgrade metadata keys while preserving other metadata + var updatedMetadata = new Dictionary(subscription.Metadata); + updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousPremiumPriceId); + updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousPeriodEndDate); + updatedMetadata.Remove(StripeConstants.MetadataKeys.UpgradedOrganizationId); + updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousPremiumUserId); + updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousAdditionalStorage); + updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousStoragePriceId); + + await _stripeFacade.UpdateSubscription(subscription.Id, new SubscriptionUpdateOptions + { + Metadata = updatedMetadata + }); + + _logger.LogInformation( + "Successfully cleaned up premium upgrade metadata for subscription {SubscriptionId}", + subscription.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to clean up premium upgrade metadata for subscription {SubscriptionId}", + subscription.Id); + // Don't throw - this is cleanup and shouldn't fail the webhook + } + } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index e9c34d7e06..cfa76d31d2 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -74,11 +74,15 @@ public static class StripeConstants public const string PreviousPeriodEndDate = "previous_period_end_date"; public const string PreviousPremiumPriceId = "previous_premium_price_id"; public const string PreviousPremiumUserId = "previous_premium_user_id"; + public const string PreviousStoragePriceId = "previous_storage_price_id"; public const string ProviderId = "providerId"; public const string Region = "region"; public const string RetiredBraintreeCustomerId = "btCustomerId_old"; public const string UserId = "userId"; public const string StorageReconciled2025 = "storage_reconciled_2025"; + + // Premium-to-Organization upgrade reversion metadata + public const string UpgradedOrganizationId = "upgraded_organization_id"; } public static class PaymentBehavior diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 81bc5c9e2c..00efd1fec6 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -138,8 +138,11 @@ public class UpgradePremiumToOrganizationCommand( { [StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(), [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId, + // Premium-to-Organization upgrade reversion metadata + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = organizationId.ToString(), [StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty, [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(), + [StripeConstants.MetadataKeys.PreviousStoragePriceId] = usersPremiumPlan.Storage.StripePriceId, [StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(), [StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 7acbe20014..aafabf9b46 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -9,6 +9,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; @@ -34,6 +35,7 @@ public class SubscriberService( IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, + IPricingClient pricingClient, IProviderRepository providerRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, @@ -57,6 +59,16 @@ public class SubscriberService( throw new BillingException(); } + // Check if this is a Premium-to-Organization upgrade that can be reverted + if (subscriber is Organization organization) + { + var canRevert = await TryRevertPremiumUpgradeAsync(subscription, organization); + if (canRevert) + { + return; + } + } + var metadata = new Dictionary { { "cancellingUserId", offboardingSurveyResponse.UserId.ToString() } @@ -962,5 +974,178 @@ public class SubscriberService( } } + /// + /// Attempts to revert a Premium-to-Organization upgrade by checking for premium upgrade metadata + /// and reverting the subscription to the original Premium plan if found. + /// + /// The Stripe subscription to check + /// The organization attempting to cancel + /// True if the reversion was successful, false if no reversion metadata was found + private async Task TryRevertPremiumUpgradeAsync(Subscription subscription, Organization organization) + { + // Extract all metadata once + var metadata = subscription.Metadata ?? new Dictionary(); + + // Check if subscription has the premium upgrade metadata + if (!metadata.TryGetValue(MetadataKeys.PreviousPremiumPriceId, out var previousPremiumPriceId) || + !metadata.TryGetValue(MetadataKeys.UpgradedOrganizationId, out var upgradedOrganizationIdStr) || + !metadata.TryGetValue(MetadataKeys.PreviousPremiumUserId, out var previousPremiumUserIdStr)) + { + return false; + } + + // Parse IDs once + if (!Guid.TryParse(upgradedOrganizationIdStr, out var upgradedOrganizationId) || + !Guid.TryParse(previousPremiumUserIdStr, out var previousPremiumUserId)) + { + logger.LogWarning("Invalid GUID format in premium upgrade metadata for subscription {SubscriptionId}", subscription.Id); + return false; + } + + // Verify that this is the organization that was upgraded + if (upgradedOrganizationId != organization.Id) + { + logger.LogWarning( + "Organization {OrganizationId} attempted to revert subscription {SubscriptionId} that belongs to organization {ActualOrganizationId}", + organization.Id, subscription.Id, upgradedOrganizationId); + return false; + } + + // Verify subscription is in trial - reversion only allowed during trial period + if (subscription.Status != SubscriptionStatus.Trialing) + { + logger.LogWarning( + "Cannot revert subscription {SubscriptionId} - not in trial period (current status: {Status})", + subscription.Id, subscription.Status); + return false; + } + + try + { + logger.LogInformation("Reverting Premium-to-Organization upgrade for organization {OrganizationId}", organization.Id); + + // STEP 1: Validate user exists BEFORE making any Stripe changes + var user = await userRepository.GetByIdAsync(previousPremiumUserId); + if (user == null) + { + logger.LogError("Cannot revert subscription - user {UserId} not found", previousPremiumUserId); + throw new BillingException(message: "Cannot revert subscription - original Premium user not found"); + } + + // STEP 2: Get the current Premium plan details from pricing client + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + if (premiumPlan == null || !premiumPlan.Available) + { + logger.LogError("Cannot revert subscription - Premium plan is not available"); + throw new BillingException("Premium plan is not currently available"); + } + + // STEP 3: Parse additional metadata + DateTime? periodEnd = null; + if (metadata.TryGetValue(MetadataKeys.PreviousPeriodEndDate, out var periodEndStr) && + DateTime.TryParse(periodEndStr, out var parsedPeriodEnd)) + { + periodEnd = parsedPeriodEnd; + } + + int additionalStorageGb = 0; + if (metadata.TryGetValue(MetadataKeys.PreviousAdditionalStorage, out var storageStr) && + int.TryParse(storageStr, out var storage)) + { + additionalStorageGb = storage; + } + + // Get previous storage price ID (if available) or fallback to current + string storagePriceId = premiumPlan.Storage.StripePriceId; // Default to current + if (metadata.TryGetValue(MetadataKeys.PreviousStoragePriceId, out var previousStoragePriceId)) + { + storagePriceId = previousStoragePriceId; // Use historical price if available + } + + // STEP 4: Build subscription items - delete all existing organization items and add Premium items + var subscriptionItemOptions = new List(); + + // Delete all existing organization subscription items + foreach (var existingItem in subscription.Items.Data) + { + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Id = existingItem.Id, + Deleted = true + }); + } + + // Add Premium seat item - use the original price from metadata + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Price = previousPremiumPriceId, + Quantity = 1 + }); + + // Add storage item if user had additional storage + if (additionalStorageGb > 0) + { + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Price = storagePriceId, + Quantity = additionalStorageGb + }); + + logger.LogInformation("Restoring {StorageGb}GB additional storage for user {UserId}", additionalStorageGb, previousPremiumUserId); + } + + // STEP 5: Preserve important metadata and remove premium upgrade metadata + var updatedMetadata = new Dictionary(metadata); + updatedMetadata.Remove(MetadataKeys.PreviousPremiumPriceId); + updatedMetadata.Remove(MetadataKeys.PreviousPeriodEndDate); + updatedMetadata.Remove(MetadataKeys.UpgradedOrganizationId); + updatedMetadata.Remove(MetadataKeys.PreviousPremiumUserId); + updatedMetadata.Remove(MetadataKeys.PreviousAdditionalStorage); + updatedMetadata.Remove(MetadataKeys.PreviousStoragePriceId); + + // Ensure user ID is in metadata + updatedMetadata[MetadataKeys.UserId] = previousPremiumUserId.ToString(); + + // Remove organization ID from metadata since this is now a user subscription + updatedMetadata.Remove(MetadataKeys.OrganizationId); + + // STEP 6: Update Stripe subscription + var updateOptions = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + TrialEnd = SubscriptionTrialEnd.Now, // End the trial immediately + Metadata = updatedMetadata + }; + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, updateOptions); + + // STEP 7: Update user's Premium status and storage + user.Premium = true; + user.PremiumExpirationDate = periodEnd; + user.GatewaySubscriptionId = subscription.Id; + user.GatewayCustomerId = subscription.CustomerId; // Ensure user's customer ID matches subscription + user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + additionalStorageGb); + + await userRepository.ReplaceAsync(user); + + // STEP 8: Clear organization's subscription reference + organization.GatewaySubscriptionId = null; + await organizationRepository.ReplaceAsync(organization); + + logger.LogInformation( + "Successfully reverted Premium-to-Organization upgrade for organization {OrganizationId}. Subscription {SubscriptionId} restored to user {UserId}", + organization.Id, subscription.Id, previousPremiumUserId); + + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to revert Premium-to-Organization upgrade for organization {OrganizationId}", organization.Id); + throw new BillingException( + message: "Failed to revert subscription upgrade", + innerException: ex); + } + } + #endregion } diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 182f09e163..124f9032b2 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -570,6 +571,8 @@ public class SubscriptionUpdatedHandlerTests _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) .Returns(Tuple.Create(null, userId, null)); + _pricingClient.ListPremiumPlans().Returns(new List()); + // Act await _sut.HandleAsync(parsedEvent); @@ -1132,6 +1135,193 @@ public class SubscriptionUpdatedHandlerTests .ListInvoices(Arg.Any()); } + [Fact] + public async Task HandleAsync_TrialToActive_WithPremiumUpgradeMetadata_CleansUpMetadata() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeSubscriptionStatus.Active, + Items = new StripeList + { + Data = [new SubscriptionItem { Plan = new Plan { Id = "test-plan" } }] + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020", + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = organizationId.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = Guid.NewGuid().ToString(), + [StripeConstants.MetadataKeys.PreviousPeriodEndDate] = DateTime.UtcNow.ToString("O"), + [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "5", + [StripeConstants.MetadataKeys.PreviousStoragePriceId] = "storage-annually-2020", + ["other_metadata"] = "should_remain" + } + }; + + var previousSubscription = new Subscription + { + Id = "sub_test", + Status = StripeSubscriptionStatus.Trialing + }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; + + _stripeEventService.GetSubscription(parsedEvent, true, Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + var organization = new Organization { Id = organizationId, GatewaySubscriptionId = "sub_test", PlanType = PlanType.EnterpriseAnnually2023 }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var plan = new Enterprise2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_test", + Arg.Is(opts => + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UpgradedOrganizationId) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumUserId) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousStoragePriceId) && + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && // Organization ID preserved + opts.Metadata.ContainsKey("other_metadata"))); // Other metadata preserved + } + + [Fact] + public async Task HandleAsync_TrialToActive_WithoutOrganizationId_SkipsCleanup() + { + // Arrange - Subscription was already reverted (no OrganizationId) + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeSubscriptionStatus.Active, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020", + [StripeConstants.MetadataKeys.UserId] = Guid.NewGuid().ToString() + // No OrganizationId - indicates reversion already happened + } + }; + + var previousSubscription = new Subscription + { + Id = "sub_test", + Status = StripeSubscriptionStatus.Trialing + }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; + + var userId = Guid.NewGuid(); + _stripeEventService.GetSubscription(parsedEvent, true, Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, userId, null)); + + var user = new Bit.Core.Entities.User { Id = userId, Premium = false }; + _userService.GetUserByIdAsync(userId).Returns(user); + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + Seat = new PremiumPurchasable { StripePriceId = "premium", Price = 10m }, + Storage = new PremiumPurchasable { StripePriceId = "storage", Price = 4m } + }; + _pricingClient.ListPremiumPlans().Returns(new List { premiumPlan }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - cleanup should be skipped because OrganizationId is missing (race condition detected) + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(opts => + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId))); + } + + [Fact] + public async Task HandleAsync_TrialToActive_WithoutPremiumUpgradeMetadata_SkipsCleanup() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeSubscriptionStatus.Active, + Items = new StripeList + { + Data = [new SubscriptionItem { Plan = new Plan { Id = "test-plan" } }] + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString() + // No premium upgrade metadata + } + }; + + var previousSubscription = new Subscription + { + Id = "sub_test", + Status = StripeSubscriptionStatus.Trialing + }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; + + _stripeEventService.GetSubscription(parsedEvent, true, Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + var organization = new Organization { Id = organizationId, GatewaySubscriptionId = "sub_test", PlanType = PlanType.EnterpriseAnnually2023 }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var plan = new Enterprise2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - cleanup should not be called because there's no premium upgrade metadata + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(opts => + opts.Metadata != null)); + } + public static IEnumerable GetNonActiveSubscriptions() { return new List diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index e686d04009..2833ce3a88 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -397,6 +397,9 @@ public class UpgradePremiumToOrganizationCommandTests opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) && opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "0" && + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UpgradedOrganizationId) && + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousStoragePriceId) && + opts.Metadata[StripeConstants.MetadataKeys.PreviousStoragePriceId] == "personal-storage-gb-annually" && opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) && opts.Metadata[StripeConstants.MetadataKeys.UserId] == string.Empty)); // Removes userId to unlink from User } @@ -600,6 +603,8 @@ public class UpgradePremiumToOrganizationCommandTests Arg.Is(opts => opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" && + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousStoragePriceId) && + opts.Metadata[StripeConstants.MetadataKeys.PreviousStoragePriceId] == "personal-storage-gb-annually" && opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat opts.Items.Count(i => i.Deleted == true) == 2)); } diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 2f938065e5..24b4027248 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; @@ -195,6 +196,580 @@ public class SubscriberServiceTests .CancelSubscriptionAsync(Arg.Any(), Arg.Any()); } + [Theory, BitAutoData] + public async Task CancelSubscription_WithPremiumUpgradeMetadata_DuringTrial_RevertsToOriginalPremiumPlan( + SutProvider sutProvider) + { + // Arrange + var organization = new Organization + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test" + }; + + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User + { + Id = userId, + Premium = false, + GatewaySubscriptionId = null + }; + + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeConstants.SubscriptionStatus.Trialing, + CustomerId = "cus_test", + Items = new StripeList + { + Data = new List + { + new() { Id = "si_org_seat", Price = new Price { Id = "org-seat-price" } } + } + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020", + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(), + [StripeConstants.MetadataKeys.PreviousPeriodEndDate] = DateTime.UtcNow.AddMonths(1).ToString("O"), + [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "5", + [StripeConstants.MetadataKeys.PreviousStoragePriceId] = "storage-annually-2020", + [StripeConstants.MetadataKeys.OrganizationId] = organization.Id.ToString() + } + }; + + var premiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan + { + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-annually", + Price = 4m, + Provided = 1 + }, + Available = true + }; + + var stripeAdapter = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var pricingClient = sutProvider.GetDependency(); + + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription); + userRepository.GetByIdAsync(userId).Returns(user); + pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); + + // Act + await sutProvider.Sut.CancelSubscription( + organization, + new OffboardingSurveyResponse { UserId = Guid.NewGuid() }, + false); + + // Assert + await stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_test", + Arg.Is((SubscriptionUpdateOptions opts) => + opts.Items != null && + opts.Items.Count == 3 && // 1 delete + 1 premium seat + 1 storage + opts.Items.Count(i => i.Deleted == true) == 1 && + opts.Items.Any(i => i.Price == "premium-annually-2020" && i.Quantity == 1) && + opts.Items.Any(i => i.Price == "storage-annually-2020" && i.Quantity == 5) && + opts.TrialEnd.Value == SubscriptionTrialEnd.Now && + opts.Metadata[StripeConstants.MetadataKeys.UserId] == userId.ToString() && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId))); + + await userRepository.Received(1).ReplaceAsync(Arg.Is(u => + u.Id == userId && + u.Premium == true && + u.GatewaySubscriptionId == "sub_test")); + + await organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => + o.Id == organization.Id && + o.GatewaySubscriptionId == null)); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_WithPremiumUpgradeMetadata_AfterTrial_DoesNotRevert( + SutProvider sutProvider) + { + // Arrange + var organization = new Organization + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test" + }; + + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeConstants.SubscriptionStatus.Active, // Already active, not trialing + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020", + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = Guid.NewGuid().ToString(), + [StripeConstants.MetadataKeys.OrganizationId] = organization.Id.ToString() + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription); + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = Guid.NewGuid(), + Reason = "other", + Feedback = "test" + }; + + // Act + await sutProvider.Sut.CancelSubscription(organization, offboardingSurveyResponse, false); + + // Assert - should fall through to standard cancellation, not reversion + await stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_test", + Arg.Is(opts => + opts.CancelAtPeriodEnd == true)); // Standard cancellation + } + + [Theory, BitAutoData] + public async Task CancelSubscription_WithoutPremiumUpgradeMetadata_UsesStandardCancellation( + Organization organization, + SutProvider sutProvider) + { + // Arrange + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeConstants.SubscriptionStatus.Active, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.OrganizationId] = organization.Id.ToString() + // No premium upgrade metadata + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription); + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = Guid.NewGuid(), + Reason = "other", + Feedback = "test" + }; + + // Act + await sutProvider.Sut.CancelSubscription(organization, offboardingSurveyResponse, false); + + // Assert - should use standard cancellation + await stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_test", + Arg.Is(opts => + opts.CancelAtPeriodEnd == true)); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_WithPremiumUpgradeMetadata_UserNotFound_ThrowsBillingException( + SutProvider sutProvider) + { + // Arrange + var organization = new Organization + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test" + }; + + var userId = Guid.NewGuid(); + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeConstants.SubscriptionStatus.Trialing, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually", + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString() + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription); + userRepository.GetByIdAsync(userId).ReturnsNull(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CancelSubscription( + organization, + new OffboardingSurveyResponse { UserId = Guid.NewGuid() }, + false)); + + Assert.Equal("Failed to revert subscription upgrade", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + Assert.Equal("Cannot revert subscription - original Premium user not found", exception.InnerException.Message); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_WithPremiumUpgradeMetadata_WrongOrganizationId_DoesNotRevert( + SutProvider sutProvider) + { + // Arrange + var organization = new Organization + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test" + }; + + var differentOrgId = Guid.NewGuid(); + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeConstants.SubscriptionStatus.Active, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually", + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = differentOrgId.ToString(), // Wrong org + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = Guid.NewGuid().ToString() + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription); + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = Guid.NewGuid(), + Reason = "other", + Feedback = "test" + }; + + // Act + await sutProvider.Sut.CancelSubscription(organization, offboardingSurveyResponse, false); + + // Assert - should fall through to standard cancellation + await stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_test", + Arg.Is(opts => + opts.CancelAtPeriodEnd == true)); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_DuringReversion_UsesHistoricalSeatPriceFromMetadata( + SutProvider sutProvider) + { + // Arrange + var organization = new Organization + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test" + }; + + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User { Id = userId, Premium = false }; + + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeConstants.SubscriptionStatus.Trialing, + CustomerId = "cus_test", + Items = new StripeList + { + Data = new List + { + new() { Id = "si_org", Price = new Price { Id = "org-price" } } + } + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020", // Historical + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(), + [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "0" + } + }; + + var currentPremiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan + { + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", // Current (different from metadata) + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-annually", + Price = 4m, + Provided = 1 + }, + Available = true + }; + + var stripeAdapter = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var pricingClient = sutProvider.GetDependency(); + + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription); + userRepository.GetByIdAsync(userId).Returns(user); + pricingClient.GetAvailablePremiumPlan().Returns(currentPremiumPlan); + + // Act + await sutProvider.Sut.CancelSubscription( + organization, + new OffboardingSurveyResponse { UserId = Guid.NewGuid() }, + false); + + // Assert - should use historical price, not current + await stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_test", + Arg.Is(opts => + opts.Items.Any(i => i.Price == "premium-annually-2020"))); // Historical, not "premium-annually" + } + + [Theory, BitAutoData] + public async Task CancelSubscription_DuringReversion_UsesHistoricalStoragePriceFromMetadata( + SutProvider sutProvider) + { + // Arrange + var organization = new Organization + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test" + }; + + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User { Id = userId, Premium = false }; + + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeConstants.SubscriptionStatus.Trialing, + CustomerId = "cus_test", + Items = new StripeList + { + Data = new List + { + new() { Id = "si_org", Price = new Price { Id = "org-price" } } + } + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually", + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(), + [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "3", + [StripeConstants.MetadataKeys.PreviousStoragePriceId] = "storage-annually-2020" // Historical + } + }; + + var currentPremiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan + { + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-annually", // Current (different from metadata) + Price = 4m, + Provided = 1 + }, + Available = true + }; + + var stripeAdapter = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var pricingClient = sutProvider.GetDependency(); + + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription); + userRepository.GetByIdAsync(userId).Returns(user); + pricingClient.GetAvailablePremiumPlan().Returns(currentPremiumPlan); + + // Act + await sutProvider.Sut.CancelSubscription( + organization, + new OffboardingSurveyResponse { UserId = Guid.NewGuid() }, + false); + + // Assert - should use historical storage price, not current + await stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_test", + Arg.Is(opts => + opts.Items.Any(i => i.Price == "storage-annually-2020" && i.Quantity == 3))); // Historical, not "storage-annually" + } + + [Theory, BitAutoData] + public async Task CancelSubscription_DuringReversion_WithoutStoragePriceId_FallsBackToCurrentPrice( + SutProvider sutProvider) + { + // Arrange + var organization = new Organization + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test" + }; + + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User { Id = userId, Premium = false }; + + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeConstants.SubscriptionStatus.Trialing, + CustomerId = "cus_test", + Items = new StripeList + { + Data = new List + { + new() { Id = "si_org", Price = new Price { Id = "org-price" } } + } + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually", + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(), + [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "2" + // No PreviousStoragePriceId - should fallback to current + } + }; + + var currentPremiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan + { + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-annually-current", // Current price + Price = 4m, + Provided = 1 + }, + Available = true + }; + + var stripeAdapter = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var pricingClient = sutProvider.GetDependency(); + + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription); + userRepository.GetByIdAsync(userId).Returns(user); + pricingClient.GetAvailablePremiumPlan().Returns(currentPremiumPlan); + + // Act + await sutProvider.Sut.CancelSubscription( + organization, + new OffboardingSurveyResponse { UserId = Guid.NewGuid() }, + false); + + // Assert - should fallback to current storage price + await stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_test", + Arg.Is(opts => + opts.Items.Any(i => i.Price == "storage-annually-current" && i.Quantity == 2))); // Current price used + } + + [Theory, BitAutoData] + public async Task CancelSubscription_DuringReversion_RemovesAllPremiumUpgradeMetadata( + SutProvider sutProvider) + { + // Arrange + var organization = new Organization + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test" + }; + + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User { Id = userId, Premium = false }; + + var subscription = new Subscription + { + Id = "sub_test", + Status = StripeConstants.SubscriptionStatus.Trialing, + CustomerId = "cus_test", + Items = new StripeList + { + Data = new List + { + new() { Id = "si_org", Price = new Price { Id = "org-price" } } + } + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually", + [StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(), + [StripeConstants.MetadataKeys.PreviousPeriodEndDate] = DateTime.UtcNow.AddMonths(1).ToString("O"), + [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "5", + [StripeConstants.MetadataKeys.PreviousStoragePriceId] = "storage-annually", + [StripeConstants.MetadataKeys.OrganizationId] = organization.Id.ToString(), + ["other_metadata"] = "should_remain" + } + }; + + var premiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan + { + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-annually", + Price = 4m, + Provided = 1 + }, + Available = true + }; + + var stripeAdapter = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var pricingClient = sutProvider.GetDependency(); + + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription); + userRepository.GetByIdAsync(userId).Returns(user); + pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); + + // Act + await sutProvider.Sut.CancelSubscription( + organization, + new OffboardingSurveyResponse { UserId = Guid.NewGuid() }, + false); + + // Assert - all premium upgrade metadata should be removed + await stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_test", + Arg.Is(opts => + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UpgradedOrganizationId) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumUserId) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousStoragePriceId) && + !opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) && + opts.Metadata[StripeConstants.MetadataKeys.UserId] == userId.ToString() && + opts.Metadata.ContainsKey("other_metadata"))); // Other metadata preserved + } + #endregion #region GetCustomer