1
0
mirror of https://github.com/bitwarden/server synced 2026-01-14 06:23:46 +00:00

[PM-30460] update storage job to also update database max storage (#6803)

* [PM-30460] update storage job to also update database max storage

* dry run logs

* more logging fixes and pr feedback, forgot sql scripts

* claude feedback

* pr feedback, redesign of entity id reverse lookup

* claude feedback
This commit is contained in:
Kyle Denney
2026-01-07 10:38:27 -06:00
committed by GitHub
parent f2aa742f76
commit 3f2ee5b029
2 changed files with 850 additions and 13 deletions

View File

@@ -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<ReconcileAdditionalStorageJob> 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<string>();
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<string, string>());
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<bool> 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()

View File

@@ -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<ReconcileAdditionalStorageJob> _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<IStripeFacade>();
_logger = Substitute.For<ILogger<ReconcileAdditionalStorageJob>>();
_featureService = Substitute.For<IFeatureService>();
_sut = new ReconcileAdditionalStorageJob(_stripeFacade, _logger, _featureService);
_userRepository = Substitute.For<IUserRepository>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>();
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(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<string, string> { ["userId"] = userId.ToString() };
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
// Mock GetIdsFromMetadata to return userId
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
@@ -111,6 +162,150 @@ public class ReconcileAdditionalStorageJobTests
Arg.Is<SubscriptionUpdateOptions>(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<string, string> { ["userId"] = userId.ToString() };
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Mock GetIdsFromMetadata to return userId
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
// Act
await _sut.Execute(context);
// Assert - Verify Stripe update happened
await _stripeFacade.Received(1).UpdateSubscription(
"sub_personal",
Arg.Is<SubscriptionUpdateOptions>(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<string, string> { ["organizationId"] = organizationId.ToString() };
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Mock GetIdsFromMetadata to return organizationId
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(organizationId, null, null));
// Act
await _sut.Execute(context);
// Assert - Verify Stripe update happened
await _stripeFacade.Received(1).UpdateSubscription(
"sub_org",
Arg.Is<SubscriptionUpdateOptions>(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<string, string> { ["userId"] = userId.ToString() };
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Mock GetIdsFromMetadata to return userId
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
// Act
await _sut.Execute(context);
// Assert - Verify Stripe update happened (item deleted)
await _stripeFacade.Received(1).UpdateSubscription(
"sub_delete",
Arg.Is<SubscriptionUpdateOptions>(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<string, string>
{
[StripeConstants.MetadataKeys.StorageReconciled2025] = "invalid-date"
};
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata);
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<string, string>
{
[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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.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<SubscriptionItem> { 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<SubscriptionItem>
{
Data = new List<SubscriptionItem> { 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<SubscriptionItem>
{
Data = subscriptionItems
}
};
}