diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs index 312ed3122b..7dfc44069c 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -4,6 +4,7 @@ using Bit.Billing.Services; using Bit.Core; using Bit.Core.Billing.Constants; using Bit.Core.Jobs; +using Bit.Core.Repositories; using Bit.Core.Services; using Quartz; using Stripe; @@ -13,12 +14,23 @@ namespace Bit.Billing.Jobs; public class ReconcileAdditionalStorageJob( IStripeFacade stripeFacade, ILogger logger, - IFeatureService featureService) : BaseJob(logger) + IFeatureService featureService, + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IStripeEventUtilityService stripeEventUtilityService) : BaseJob(logger) { private const string _storageGbMonthlyPriceId = "storage-gb-monthly"; private const string _storageGbAnnuallyPriceId = "storage-gb-annually"; private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually"; private const int _storageGbToRemove = 4; + private const short _includedStorageGb = 5; + + public enum SubscriptionPlanTier + { + Personal, + Organization, + Unknown + } protected override async Task ExecuteJobAsync(IJobExecutionContext context) { @@ -34,6 +46,7 @@ public class ReconcileAdditionalStorageJob( var subscriptionsFound = 0; var subscriptionsUpdated = 0; var subscriptionsWithErrors = 0; + var databaseUpdatesFailed = 0; var failures = new List(); logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode); @@ -51,11 +64,13 @@ public class ReconcileAdditionalStorageJob( { logger.LogWarning( "Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " + - "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + "Stripe updates: {StripeUpdates}, Database updates: {DatabaseFailed} failed, " + + "Errors: {SubscriptionsWithErrors}{Failures}", subscriptionsFound, liveMode ? subscriptionsUpdated : $"(In live mode, would have updated) {subscriptionsUpdated}", + databaseUpdatesFailed, subscriptionsWithErrors, failures.Count > 0 ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" @@ -99,20 +114,68 @@ public class ReconcileAdditionalStorageJob( subscriptionsUpdated++; - if (!liveMode) + // Now, prepare the database update so we can log details out if not in live mode + var (organizationId, userId, _) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata ?? new Dictionary()); + var subscriptionPlanTier = DetermineSubscriptionPlanTier(userId, organizationId); + + if (subscriptionPlanTier == SubscriptionPlanTier.Unknown) { - logger.LogInformation( - "Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}", - subscription.Id, - Environment.NewLine, - JsonSerializer.Serialize(updateOptions)); + logger.LogError( + "Cannot determine subscription plan tier for {SubscriptionId}. Skipping subscription. ", + subscription.Id); + subscriptionsWithErrors++; continue; } + var entityId = + subscriptionPlanTier switch + { + SubscriptionPlanTier.Personal => userId!.Value, + SubscriptionPlanTier.Organization => organizationId!.Value, + _ => throw new ArgumentOutOfRangeException(nameof(subscriptionPlanTier), subscriptionPlanTier, null) + }; + + // Calculate new MaxStorageGb + var currentStorageQuantity = GetCurrentStorageQuantityFromSubscription(subscription, priceId); + var newMaxStorageGb = CalculateNewMaxStorageGb(currentStorageQuantity, updateOptions); + + if (!liveMode) + { + logger.LogInformation( + "Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}" + + "{NewLine2}And would have updated database record tier: {Tier} to new MaxStorageGb: {MaxStorageGb}", + subscription.Id, + Environment.NewLine, + JsonSerializer.Serialize(updateOptions), + Environment.NewLine, + subscriptionPlanTier, + newMaxStorageGb); + continue; + } + + // Live mode enabled - continue with updates to stripe and database try { await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); - logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id); + logger.LogInformation("Successfully updated Stripe subscription: {SubscriptionId}", subscription.Id); + + logger.LogInformation( + "Updating MaxStorageGb in database for subscription {SubscriptionId} ({Type}): New MaxStorageGb: {MaxStorage}", + subscription.Id, + subscriptionPlanTier, + newMaxStorageGb); + + var dbUpdateSuccess = await UpdateDatabaseMaxStorageAsync( + subscriptionPlanTier, + entityId, + newMaxStorageGb, + subscription.Id); + + if (!dbUpdateSuccess) + { + databaseUpdatesFailed++; + failures.Add($"Subscription {subscription.Id}: Database update failed"); + } } catch (Exception ex) { @@ -125,12 +188,14 @@ public class ReconcileAdditionalStorageJob( } logger.LogInformation( - "ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " + - "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + "ReconcileAdditionalStorageJob FINISHED. Subscriptions found: {SubscriptionsFound}, " + + "Subscriptions updated: {SubscriptionsUpdated}, Database failures: {DatabaseFailed}, " + + "Total Subscriptions With Errors: {SubscriptionsWithErrors}{Failures}", subscriptionsFound, liveMode ? subscriptionsUpdated : $"(In live mode, would have updated) {subscriptionsUpdated}", + databaseUpdatesFailed, subscriptionsWithErrors, failures.Count > 0 ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" @@ -182,6 +247,117 @@ public class ReconcileAdditionalStorageJob( return hasUpdates ? updateOptions : null; } + public SubscriptionPlanTier DetermineSubscriptionPlanTier( + Guid? userId, + Guid? organizationId) + { + return userId.HasValue + ? SubscriptionPlanTier.Personal + : organizationId.HasValue + ? SubscriptionPlanTier.Organization + : SubscriptionPlanTier.Unknown; + } + + public long GetCurrentStorageQuantityFromSubscription( + Subscription subscription, + string storagePriceId) + { + return subscription.Items?.Data?.FirstOrDefault(item => item?.Price?.Id == storagePriceId)?.Quantity ?? 0; + } + + public short CalculateNewMaxStorageGb( + long currentQuantity, + SubscriptionUpdateOptions? updateOptions) + { + if (updateOptions?.Items == null) + { + return (short)(_includedStorageGb + currentQuantity); + } + + // If the update marks item as deleted, new quantity is whatever the base storage gb + if (updateOptions.Items.Any(i => i.Deleted == true)) + { + return _includedStorageGb; + } + + // If the update has a new quantity, use it to calculate the new max + var updatedItem = updateOptions.Items.FirstOrDefault(i => i.Quantity.HasValue); + if (updatedItem?.Quantity != null) + { + return (short)(_includedStorageGb + updatedItem.Quantity.Value); + } + + // Otherwise, no change + return (short)(_includedStorageGb + currentQuantity); + } + + public async Task UpdateDatabaseMaxStorageAsync( + SubscriptionPlanTier subscriptionPlanTier, + Guid entityId, + short newMaxStorageGb, + string subscriptionId) + { + try + { + switch (subscriptionPlanTier) + { + case SubscriptionPlanTier.Personal: + { + var user = await userRepository.GetByIdAsync(entityId); + if (user == null) + { + logger.LogError( + "User not found for subscription {SubscriptionId}. Database not updated.", + subscriptionId); + return false; + } + + user.MaxStorageGb = newMaxStorageGb; + await userRepository.ReplaceAsync(user); + + logger.LogInformation( + "Successfully updated User {UserId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}", + user.Id, + newMaxStorageGb, + subscriptionId); + return true; + } + case SubscriptionPlanTier.Organization: + { + var organization = await organizationRepository.GetByIdAsync(entityId); + if (organization == null) + { + logger.LogError( + "Organization not found for subscription {SubscriptionId}. Database not updated.", + subscriptionId); + return false; + } + + organization.MaxStorageGb = newMaxStorageGb; + await organizationRepository.ReplaceAsync(organization); + + logger.LogInformation( + "Successfully updated Organization {OrganizationId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}", + organization.Id, + newMaxStorageGb, + subscriptionId); + return true; + } + case SubscriptionPlanTier.Unknown: + default: + return false; + } + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to update database MaxStorageGb for subscription {SubscriptionId} (Plan Tier: {SubscriptionType})", + subscriptionId, + subscriptionPlanTier); + return false; + } + } + public static ITrigger GetTrigger() { return TriggerBuilder.Create() diff --git a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs index b3540246b0..34ac030453 100644 --- a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs +++ b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs @@ -2,6 +2,7 @@ using Bit.Billing.Services; using Bit.Core; using Bit.Core.Billing.Constants; +using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; using NSubstitute; @@ -17,6 +18,9 @@ public class ReconcileAdditionalStorageJobTests private readonly IStripeFacade _stripeFacade; private readonly ILogger _logger; private readonly IFeatureService _featureService; + private readonly IUserRepository _userRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly ReconcileAdditionalStorageJob _sut; public ReconcileAdditionalStorageJobTests() @@ -24,7 +28,20 @@ public class ReconcileAdditionalStorageJobTests _stripeFacade = Substitute.For(); _logger = Substitute.For>(); _featureService = Substitute.For(); - _sut = new ReconcileAdditionalStorageJob(_stripeFacade, _logger, _featureService); + _userRepository = Substitute.For(); + _organizationRepository = Substitute.For(); + _stripeEventUtilityService = Substitute.For(); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, null, null)); + + _sut = new ReconcileAdditionalStorageJob( + _stripeFacade, + _logger, + _featureService, + _userRepository, + _organizationRepository, + _stripeEventUtilityService); } #region Feature Flag Tests @@ -88,6 +105,36 @@ public class ReconcileAdditionalStorageJobTests await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); } + [Fact] + public async Task Execute_DryRunMode_DoesNotUpdateDatabase() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); // Dry run ON + + // Create a personal subscription that would normally trigger a database update + var userId = Guid.NewGuid(); + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + subscription.Metadata = new Dictionary { ["userId"] = userId.ToString() }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Mock GetIdsFromMetadata to return userId + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.Execute(context); + + // Assert - Verify database repositories are never called + await _userRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + await _organizationRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + [Fact] public async Task Execute_DryRunModeDisabled_UpdatesSubscriptions() { @@ -96,7 +143,11 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); // Dry run OFF + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) @@ -111,6 +162,150 @@ public class ReconcileAdditionalStorageJobTests Arg.Is(o => o.Items.Count == 1)); } + [Fact] + public async Task Execute_LiveMode_PersonalSubscription_UpdatesUserDatabase() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + // Setup user + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User + { + Id = userId, + Email = "test@example.com", + GatewaySubscriptionId = "sub_personal", + MaxStorageGb = 15 // Old value + }; + _userRepository.GetByIdAsync(userId).Returns(user); + _userRepository.ReplaceAsync(user).Returns(Task.CompletedTask); + + // Create personal subscription with premium seat + 10 GB storage (will be reduced to 6 GB) + var subscription = CreateSubscriptionWithMultipleItems("sub_personal", + [("premium-annually", 1L), ("storage-gb-monthly", 10L)]); + subscription.Metadata = new Dictionary { ["userId"] = userId.ToString() }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Mock GetIdsFromMetadata to return userId + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.Execute(context); + + // Assert - Verify Stripe update happened + await _stripeFacade.Received(1).UpdateSubscription( + "sub_personal", + Arg.Is(o => o.Items.Count == 1 && o.Items[0].Quantity == 6)); + + // Assert - Verify database update with correct MaxStorageGb (5 included + 6 new quantity = 11) + await _userRepository.Received(1).GetByIdAsync(userId); + await _userRepository.Received(1).ReplaceAsync(user); + Assert.Equal((short)11, user.MaxStorageGb); + } + + [Fact] + public async Task Execute_LiveMode_OrganizationSubscription_UpdatesOrganizationDatabase() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + // Setup organization + var organizationId = Guid.NewGuid(); + var organization = new Bit.Core.AdminConsole.Entities.Organization + { + Id = organizationId, + Name = "Test Organization", + GatewaySubscriptionId = "sub_org", + MaxStorageGb = 13 // Old value + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _organizationRepository.ReplaceAsync(organization).Returns(Task.CompletedTask); + + // Create organization subscription with org seat plan + 8 GB storage (will be reduced to 4 GB) + var subscription = CreateSubscriptionWithMultipleItems("sub_org", + [("2023-teams-org-seat-annually", 5L), ("storage-gb-monthly", 8L)]); + subscription.Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Mock GetIdsFromMetadata to return organizationId + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + // Act + await _sut.Execute(context); + + // Assert - Verify Stripe update happened + await _stripeFacade.Received(1).UpdateSubscription( + "sub_org", + Arg.Is(o => o.Items.Count == 1 && o.Items[0].Quantity == 4)); + + // Assert - Verify database update with correct MaxStorageGb (5 included + 4 new quantity = 9) + await _organizationRepository.Received(1).GetByIdAsync(organizationId); + await _organizationRepository.Received(1).ReplaceAsync(organization); + Assert.Equal((short)9, organization.MaxStorageGb); + } + + [Fact] + public async Task Execute_LiveMode_StorageItemDeleted_UpdatesDatabaseWithBaseStorage() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + // Setup user + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User + { + Id = userId, + Email = "test@example.com", + GatewaySubscriptionId = "sub_delete", + MaxStorageGb = 8 // Old value + }; + _userRepository.GetByIdAsync(userId).Returns(user); + _userRepository.ReplaceAsync(user).Returns(Task.CompletedTask); + + // Create personal subscription with premium seat + 3 GB storage (will be deleted since 3 < 4) + var subscription = CreateSubscriptionWithMultipleItems("sub_delete", + [("premium-annually", 1L), ("storage-gb-monthly", 3L)]); + subscription.Metadata = new Dictionary { ["userId"] = userId.ToString() }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Mock GetIdsFromMetadata to return userId + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.Execute(context); + + // Assert - Verify Stripe update happened (item deleted) + await _stripeFacade.Received(1).UpdateSubscription( + "sub_delete", + Arg.Is(o => o.Items.Count == 1 && o.Items[0].Deleted == true)); + + // Assert - Verify database update with base storage only (5 GB) + await _userRepository.Received(1).GetByIdAsync(userId); + await _userRepository.Received(1).ReplaceAsync(user); + Assert.Equal((short)5, user.MaxStorageGb); + } + #endregion #region Price ID Processing Tests @@ -174,11 +369,14 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var metadata = new Dictionary { [StripeConstants.MetadataKeys.StorageReconciled2025] = "invalid-date" }; var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -200,7 +398,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: null); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -226,7 +427,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -253,7 +457,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 4); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -279,7 +486,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 2); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -309,7 +519,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -333,7 +546,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -429,9 +645,12 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5); var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); @@ -461,6 +680,7 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var processedMetadata = new Dictionary { [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") @@ -469,6 +689,8 @@ public class ReconcileAdditionalStorageJobTests var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5, metadata: processedMetadata); var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); @@ -501,9 +723,12 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5); var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); @@ -563,7 +788,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -585,7 +813,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Trialing); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -607,7 +838,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.PastDue); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -669,11 +903,14 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var activeSubscription = CreateSubscription("sub_active", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active); var trialingSubscription = CreateSubscription("sub_trialing", "storage-gb-monthly", quantity: 8, status: StripeConstants.SubscriptionStatus.Trialing); var pastDueSubscription = CreateSubscription("sub_pastdue", "storage-gb-monthly", quantity: 6, status: StripeConstants.SubscriptionStatus.PastDue); var canceledSubscription = CreateSubscription("sub_canceled", "storage-gb-monthly", quantity: 5, status: StripeConstants.SubscriptionStatus.Canceled); var incompleteSubscription = CreateSubscription("sub_incomplete", "storage-gb-monthly", quantity: 4, status: StripeConstants.SubscriptionStatus.Incomplete); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(activeSubscription, trialingSubscription, pastDueSubscription, canceledSubscription, incompleteSubscription)); @@ -731,6 +968,410 @@ public class ReconcileAdditionalStorageJobTests #endregion + #region Helper Method Tests + + #region DetermineSubscriptionPlanTier Tests + + [Fact] + public void DetermineSubscriptionPlanTier_WithUserId_ReturnsPersonal() + { + // Arrange + var userId = Guid.NewGuid(); + Guid? organizationId = null; + + // Act + var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId); + + // Assert + Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, result); + } + + [Fact] + public void DetermineSubscriptionPlanTier_WithOrganizationId_ReturnsOrganization() + { + // Arrange + Guid? userId = null; + var organizationId = Guid.NewGuid(); + + // Act + var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId); + + // Assert + Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, result); + } + + [Fact] + public void DetermineSubscriptionPlanTier_WithBothIds_ReturnsPersonal() + { + // Arrange - Personal takes precedence + var userId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + + // Act + var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId); + + // Assert + Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, result); + } + + [Fact] + public void DetermineSubscriptionPlanTier_WithNoIds_ReturnsUnknown() + { + // Arrange + Guid? userId = null; + Guid? organizationId = null; + + // Act + var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId); + + // Assert + Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Unknown, result); + } + + #endregion + + #region GetCurrentStorageQuantityFromSubscription Tests + + [Theory] + [InlineData("storage-gb-monthly", 10L, 10L)] + [InlineData("storage-gb-annually", 25L, 25L)] + [InlineData("personal-storage-gb-annually", 5L, 5L)] + [InlineData("storage-gb-monthly", 0L, 0L)] + public void GetCurrentStorageQuantityFromSubscription_WithMatchingPriceId_ReturnsQuantity( + string priceId, long quantity, long expectedQuantity) + { + // Arrange + var subscription = CreateSubscription("sub_123", priceId, quantity); + + // Act + var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, priceId); + + // Assert + Assert.Equal(expectedQuantity, result); + } + + [Fact] + public void GetCurrentStorageQuantityFromSubscription_WithNonMatchingPriceId_ReturnsZero() + { + // Arrange + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", 10L); + + // Act + var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "different-price-id"); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetCurrentStorageQuantityFromSubscription_WithNullItems_ReturnsZero() + { + // Arrange + var subscription = new Subscription { Id = "sub_123", Items = null }; + + // Act + var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "storage-gb-monthly"); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetCurrentStorageQuantityFromSubscription_WithEmptyItems_ReturnsZero() + { + // Arrange + var subscription = new Subscription + { + Id = "sub_123", + Items = new StripeList { Data = [] } + }; + + // Act + var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "storage-gb-monthly"); + + // Assert + Assert.Equal(0, result); + } + + #endregion + + #region CalculateNewMaxStorageGb Tests + + [Theory] + [InlineData(10L, 6L, 11)] // 5 included + 6 new quantity + [InlineData(15L, 11L, 16)] // 5 included + 11 new quantity + [InlineData(4L, 0L, 5)] // Item deleted, returns base storage + [InlineData(2L, 0L, 5)] // Item deleted, returns base storage + [InlineData(8L, 4L, 9)] // 5 included + 4 new quantity + public void CalculateNewMaxStorageGb_WithQuantityUpdate_ReturnsCorrectMaxStorage( + long currentQuantity, long newQuantity, short expectedMaxStorageGb) + { + // Arrange + var updateOptions = new SubscriptionUpdateOptions + { + Items = + [ + newQuantity == 0 + ? new SubscriptionItemOptions { Id = "si_123", Deleted = true } // Item marked as deleted + : new SubscriptionItemOptions { Id = "si_123", Quantity = newQuantity } // Item quantity updated + ] + }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal(expectedMaxStorageGb, result); + } + + [Fact] + public void CalculateNewMaxStorageGb_WithNullUpdateOptions_ReturnsCurrentQuantityPlusBaseIncluded() + { + // Arrange + const long currentQuantity = 10; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, null); + + // Assert + Assert.Equal((short)(5 + currentQuantity), result); + } + + [Fact] + public void CalculateNewMaxStorageGb_WithNullItems_ReturnsCurrentQuantityPlusBaseIncluded() + { + // Arrange + const long currentQuantity = 10; + var updateOptions = new SubscriptionUpdateOptions { Items = null }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal(5 + currentQuantity, result); + } + + [Fact] + public void CalculateNewMaxStorageGb_WithEmptyItems_ReturnsCurrentQuantity() + { + // Arrange + const long currentQuantity = 10; + var updateOptions = new SubscriptionUpdateOptions + { + Items = [] + }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal(5 + currentQuantity, result); + } + + [Fact] + public void CalculateNewMaxStorageGb_WithDeletedItem_ReturnsBaseStorage() + { + // Arrange + const long currentQuantity = 100; + var updateOptions = new SubscriptionUpdateOptions + { + Items = [new SubscriptionItemOptions { Id = "si_123", Deleted = true }] + }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal((short)5, result); // Base storage + } + + [Fact] + public void CalculateNewMaxStorageGb_WithItemWithoutQuantity_ReturnsCurrentQuantity() + { + // Arrange + const long currentQuantity = 10; + var updateOptions = new SubscriptionUpdateOptions + { + Items = [new SubscriptionItemOptions { Id = "si_123", Quantity = null }] + }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal(5 + currentQuantity, result); + } + + #endregion + + #region UpdateDatabaseMaxStorageAsync Tests + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_UpdatesUser() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User + { + Id = userId, + Email = "test@example.com", + GatewaySubscriptionId = "sub_123" + }; + _userRepository.GetByIdAsync(userId).Returns(user); + _userRepository.ReplaceAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, + userId, + 10, + "sub_123"); + + // Assert + Assert.True(result); + Assert.Equal((short)10, user.MaxStorageGb); + await _userRepository.Received(1).GetByIdAsync(userId); + await _userRepository.Received(1).ReplaceAsync(user); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_UserNotFound_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + _userRepository.GetByIdAsync(userId).Returns((Bit.Core.Entities.User?)null); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, + userId, + 10, + "sub_123"); + + // Assert + Assert.False(result); + await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_ReplaceThrowsException_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User + { + Id = userId, + Email = "test@example.com", + GatewaySubscriptionId = "sub_123" + }; + _userRepository.GetByIdAsync(userId).Returns(user); + _userRepository.ReplaceAsync(user).Throws(new Exception("Database error")); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, + userId, + 10, + "sub_123"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_UpdatesOrganization() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Bit.Core.AdminConsole.Entities.Organization + { + Id = organizationId, + Name = "Test Org", + GatewaySubscriptionId = "sub_456" + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _organizationRepository.ReplaceAsync(organization).Returns(Task.CompletedTask); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, + organizationId, + 20, + "sub_456"); + + // Assert + Assert.True(result); + Assert.Equal((short)20, organization.MaxStorageGb); + await _organizationRepository.Received(1).GetByIdAsync(organizationId); + await _organizationRepository.Received(1).ReplaceAsync(organization); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_OrganizationNotFound_ReturnsFalse() + { + // Arrange + var organizationId = Guid.NewGuid(); + _organizationRepository.GetByIdAsync(organizationId) + .Returns((Bit.Core.AdminConsole.Entities.Organization?)null); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, + organizationId, + 20, + "sub_456"); + + // Assert + Assert.False(result); + await _organizationRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_ReplaceThrowsException_ReturnsFalse() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Bit.Core.AdminConsole.Entities.Organization + { + Id = organizationId, + Name = "Test Org", + GatewaySubscriptionId = "sub_456" + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _organizationRepository.ReplaceAsync(organization).Throws(new Exception("Database error")); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, + organizationId, + 20, + "sub_456"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_UnknownTier_ReturnsFalse() + { + // Arrange & Act + var entityId = Guid.NewGuid(); + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Unknown, + entityId, + 15, + "sub_789"); + + // Assert + Assert.False(result); + await _userRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + } + + #endregion + + #endregion + #region Helper Methods private static IJobExecutionContext CreateJobExecutionContext(CancellationToken cancellationToken = default) @@ -762,7 +1403,27 @@ public class ReconcileAdditionalStorageJobTests Metadata = metadata, Items = new StripeList { - Data = new List { item } + Data = [item] + } + }; + } + + private static Subscription CreateSubscriptionWithMultipleItems(string id, (string priceId, long quantity)[] items) + { + var subscriptionItems = items.Select(i => new SubscriptionItem + { + Id = $"si_{id}_{i.priceId}", + Price = new Price { Id = i.priceId }, + Quantity = i.quantity + }).ToList(); + + return new Subscription + { + Id = id, + Status = StripeConstants.SubscriptionStatus.Active, + Items = new StripeList + { + Data = subscriptionItems } }; }