From 67b1f1ca8c4756285e7c4c530044e87bd41614f9 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:28:47 -0600 Subject: [PATCH 01/10] [PM-28265] storage reconciliation job stage 3 stage 4 stage 5 final draft only allow in lower envs claude pr feedback tests claude feedback improvements --- src/Billing/Controllers/JobsController.cs | 38 ++ src/Billing/Jobs/AliveJob.cs | 9 + src/Billing/Jobs/JobsHostedService.cs | 22 +- .../Jobs/ReconcileAdditionalStorageJob.cs | 225 ++++++ ...oncileAdditionalStorageJobHostedService.cs | 31 + src/Billing/Services/IStripeFacade.cs | 5 + .../Services/Implementations/StripeFacade.cs | 6 + src/Billing/Startup.cs | 4 + src/Core/Billing/Constants/StripeConstants.cs | 1 + src/Core/Constants.cs | 2 + src/Core/Jobs/BaseJobsHostedService.cs | 21 +- .../RequireLowerEnvironmentAttribute.cs | 24 + .../ReconcileAdditionalStorageJobTests.cs | 641 ++++++++++++++++++ 13 files changed, 1010 insertions(+), 19 deletions(-) create mode 100644 src/Billing/Controllers/JobsController.cs create mode 100644 src/Billing/Jobs/ReconcileAdditionalStorageJob.cs create mode 100644 src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs create mode 100644 src/Core/Utilities/RequireLowerEnvironmentAttribute.cs create mode 100644 test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs diff --git a/src/Billing/Controllers/JobsController.cs b/src/Billing/Controllers/JobsController.cs new file mode 100644 index 000000000000..02bdf3ca310c --- /dev/null +++ b/src/Billing/Controllers/JobsController.cs @@ -0,0 +1,38 @@ +using Bit.Billing.Jobs; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; +using Quartz; + +namespace Bit.Billing.Controllers; + +[Route("jobs")] +[SelfHosted(NotSelfHostedOnly = true)] +[RequireLowerEnvironment] +public class JobsController( + ReconcileAdditionalStorageJobHostedService jobsHostedService, + ISchedulerFactory schedulerFactory) : Controller +{ + [HttpPost("run/{jobName}")] + public async Task RunJobAsync(string jobName) + { + if (jobName == nameof(ReconcileAdditionalStorageJob)) + { + await ReconcileAdditionalStorageJob.RunJobNowAsync(schedulerFactory); + return Ok(new { message = $"Job {jobName} scheduled successfully" }); + } + + return BadRequest(new { error = $"Unknown job name: {jobName}" }); + } + + [HttpPost("stop/{jobName}")] + public async Task StopJobAsync(string jobName) + { + if (jobName == nameof(ReconcileAdditionalStorageJob)) + { + await jobsHostedService.InterruptJobsAndShutdownAsync(); + return Ok(new { message = $"Job {jobName} stopped successfully" }); + } + + return BadRequest(new { error = $"Unknown job name: {jobName}" }); + } +} diff --git a/src/Billing/Jobs/AliveJob.cs b/src/Billing/Jobs/AliveJob.cs index 42f64099ac01..1769cc94e250 100644 --- a/src/Billing/Jobs/AliveJob.cs +++ b/src/Billing/Jobs/AliveJob.cs @@ -10,4 +10,13 @@ protected override Task ExecuteJobAsync(IJobExecutionContext context) _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!"); return Task.FromResult(0); } + + public static ITrigger GetTrigger() + { + return TriggerBuilder.Create() + .WithIdentity("EveryTopOfTheHourTrigger") + .StartNow() + .WithCronSchedule("0 0 * * * ?") + .Build(); + } } diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index a6e702c66285..bd88f59bb00a 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -4,26 +4,18 @@ namespace Bit.Billing.Jobs; -public class JobsHostedService : BaseJobsHostedService +public class JobsHostedService( + GlobalSettings globalSettings, + IServiceProvider serviceProvider, + ILogger logger, + ILogger listenerLogger) + : BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger) { - public JobsHostedService( - GlobalSettings globalSettings, - IServiceProvider serviceProvider, - ILogger logger, - ILogger listenerLogger) - : base(globalSettings, serviceProvider, logger, listenerLogger) { } - public override async Task StartAsync(CancellationToken cancellationToken) { - var everyTopOfTheHourTrigger = TriggerBuilder.Create() - .WithIdentity("EveryTopOfTheHourTrigger") - .StartNow() - .WithCronSchedule("0 0 * * * ?") - .Build(); - Jobs = new List> { - new Tuple(typeof(AliveJob), everyTopOfTheHourTrigger) + new(typeof(AliveJob), AliveJob.GetTrigger()) }; await base.StartAsync(cancellationToken); diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs new file mode 100644 index 000000000000..0896a8e31ba3 --- /dev/null +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -0,0 +1,225 @@ +using System.Globalization; +using Bit.Billing.Services; +using Bit.Core; +using Bit.Core.Billing.Constants; +using Bit.Core.Jobs; +using Bit.Core.Services; +using Quartz; +using Stripe; +using System.Text.Json; + +namespace Bit.Billing.Jobs; + +public class ReconcileAdditionalStorageJob( + IStripeFacade stripeFacade, + ILogger logger, + IFeatureService featureService) : BaseJob(logger) +{ + private const string _storageGbMonthlyPriceId = "storage-gb-monthly"; + private const string _storageGbAnnuallyPriceId = "storage-gb-annually"; + private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually"; + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + if (!featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob)) + { + logger.LogInformation("Skipping ReconcileAdditionalStorageJob, feature flag off."); + return; + } + + var liveMode = featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode); + + // Execution tracking + var subscriptionsFound = 0; + var subscriptionsUpdated = 0; + var subscriptionsWithErrors = 0; + var failures = new List(); + + logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode); + + var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId }; + + foreach (var priceId in priceIds) + { + var options = new SubscriptionListOptions { Limit = 100, Status = "active", Price = priceId }; + + await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options)) + { + if (context.CancellationToken.IsCancellationRequested) + { + logger.LogWarning( + "Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " + + "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + subscriptionsFound, + liveMode + ? subscriptionsUpdated + : $"(In live mode, would have updated) {subscriptionsUpdated}", + subscriptionsWithErrors, + failures.Count > 0 + ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" + : string.Empty + ); + return; + } + + if (subscription == null) + { + continue; + } + + if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true) + { + if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed)) + { + logger.LogInformation("Skipping subscription {SubscriptionId} - already processed on {Date}", + subscription.Id, + dateProcessed.ToString("f")); + continue; + } + } + + logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id); + subscriptionsFound++; + + var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId); + + if (updateOptions == null) + { + logger.LogInformation("Skipping subscription {SubscriptionId} - no updates needed", subscription.Id); + continue; + } + + subscriptionsUpdated++; + + if (!liveMode) + { + logger.LogInformation( + "Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}", + subscription.Id, + Environment.NewLine, + JsonSerializer.Serialize(updateOptions)); + continue; + } + + try + { + await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); + logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id); + } + catch (Exception ex) + { + subscriptionsWithErrors++; + failures.Add($"Subscription {subscription.Id}: {ex.Message}"); + logger.LogError(ex, "Failed to update subscription {SubscriptionId}: {ErrorMessage}", + subscription.Id, ex.Message); + } + } + } + + logger.LogInformation( + "ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " + + "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + subscriptionsFound, + liveMode + ? subscriptionsUpdated + : $"(In live mode, would have updated) {subscriptionsUpdated}", + subscriptionsWithErrors, + failures.Count > 0 + ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" + : string.Empty + ); + } + + private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions( + Subscription subscription, + string targetPriceId) + { + if (subscription.Items?.Data == null) + { + return null; + } + + var updateOptions = new SubscriptionUpdateOptions + { + ProrationBehavior = "always_invoice", + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") + }, + Items = [] + }; + + var hasUpdates = false; + + foreach (var item in subscription.Items.Data.Where(item => item?.Price?.Id == targetPriceId)) + { + hasUpdates = true; + var currentQuantity = item.Quantity; + + if (currentQuantity > 4) + { + var newQuantity = currentQuantity - 4; + logger.LogInformation( + "Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}", + subscription.Id, + currentQuantity, + newQuantity, + item.Price.Id); + + updateOptions.Items.Add(new SubscriptionItemOptions + { + Id = item.Id, + Quantity = newQuantity + }); + } + else + { + logger.LogInformation("Subscription {SubscriptionId}: deleting storage item with quantity {CurrentQuantity} for price {PriceId}", + subscription.Id, + currentQuantity, + item.Price.Id); + + updateOptions.Items.Add(new SubscriptionItemOptions + { + Id = item.Id, + Deleted = true + }); + } + } + + return hasUpdates ? updateOptions : null; + } + + public static ITrigger GetTrigger() + { + return TriggerBuilder.Create() + .WithIdentity("EveryMorningTrigger") + .StartNow() + .WithCronSchedule("0 0 16 * * ?") // 10am CST daily, assuming the pods execute in UTC time + .Build(); + } + + public static async Task RunJobNowAsync(ISchedulerFactory schedulerFactory) + { + var scheduler = await schedulerFactory.GetScheduler(); + + var jobKey = new JobKey(nameof(ReconcileAdditionalStorageJob)); + + var currentlyExecuting = await scheduler.GetCurrentlyExecutingJobs(); + if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey))) + { + throw new InvalidOperationException("Job is already running"); + } + + var job = JobBuilder.Create() + .WithIdentity(jobKey) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity("ReconcileAdditionalStorageJob.RunNow") + .StartNow() + .Build(); + + await scheduler.ScheduleJob(job, trigger); + } +} diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs new file mode 100644 index 000000000000..a64075bdc15f --- /dev/null +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs @@ -0,0 +1,31 @@ +using Bit.Core.Jobs; +using Bit.Core.Settings; +using Quartz; + +namespace Bit.Billing.Jobs; + +public class ReconcileAdditionalStorageJobHostedService( + GlobalSettings globalSettings, + IServiceProvider serviceProvider, + ILogger logger, + ILogger listenerLogger) + : BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger) +{ + public override async Task StartAsync(CancellationToken cancellationToken) + { + Jobs = new List> + { + new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger()), + }; + + await base.StartAsync(cancellationToken); + } + + public static void AddJobsServices(IServiceCollection services) + { + services.AddTransient(); + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + } + +} diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 280a3aca3cfb..90db4a4c825e 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -78,6 +78,11 @@ Task> ListSubscriptions( RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + IAsyncEnumerable ListSubscriptionsAutoPagingAsync( + SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index eef7ce009ee4..7b714b4a8e52 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -98,6 +98,12 @@ public async Task> ListSubscriptions(SubscriptionListOp CancellationToken cancellationToken = default) => await _subscriptionService.ListAsync(options, requestOptions, cancellationToken); + public IAsyncEnumerable ListSubscriptionsAutoPagingAsync( + SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _subscriptionService.ListAutoPagingAsync(options, requestOptions, cancellationToken); + public async Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index cdb9700ad575..82ea006ffa36 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Net.Http.Headers; +using Bit.Billing.Jobs; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Commercial.Core.Utilities; @@ -122,6 +123,9 @@ public void ConfigureServices(IServiceCollection services) Jobs.JobsHostedService.AddJobsServices(services); services.AddHostedService(); + // Specialized job service for reconcile storage job so it can be shutdown if needed + ReconcileAdditionalStorageJobHostedService.AddJobsServices(services); + // Swagger services.AddEndpointsApiExplorer(); services.AddSwaggerGen(); diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 11f043fc6955..c062351a914d 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -65,6 +65,7 @@ public static class MetadataKeys public const string Region = "region"; public const string RetiredBraintreeCustomerId = "btCustomerId_old"; public const string UserId = "userId"; + public const string StorageReconciled2025 = "storage_reconciled_2025"; } public static class PaymentBehavior diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c4f8f6458efb..e465b9ea8bd3 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -197,6 +197,8 @@ public static class FeatureFlagKeys public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; public const string PM23341_Milestone_2 = "pm-23341-milestone-2"; public const string PM26462_Milestone_3 = "pm-26462-milestone-3"; + public const string PM28265_EnableReconcileAdditionalStorageJob = "pm-28265-enable-reconcile-additional-storage-job"; + public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/Jobs/BaseJobsHostedService.cs b/src/Core/Jobs/BaseJobsHostedService.cs index 8b74052f8ff5..6dc4f5a22545 100644 --- a/src/Core/Jobs/BaseJobsHostedService.cs +++ b/src/Core/Jobs/BaseJobsHostedService.cs @@ -33,7 +33,8 @@ public BaseJobsHostedService( _globalSettings = globalSettings; } - public IEnumerable>? Jobs { get; protected set; } + protected IEnumerable>? Jobs { get; set; } + protected IList JobKeys { get; private set; } = new List(); public virtual async Task StartAsync(CancellationToken cancellationToken) { @@ -64,14 +65,13 @@ public virtual async Task StartAsync(CancellationToken cancellationToken) GroupMatcher.AnyGroup()); await _scheduler.Start(cancellationToken); - var jobKeys = new List(); var triggerKeys = new List(); if (Jobs != null) { foreach (var (job, trigger) in Jobs) { - jobKeys.Add(JobBuilder.Create(job) + JobKeys.Add(JobBuilder.Create(job) .WithIdentity(job.FullName!) .Build().Key); triggerKeys.Add(trigger.Key); @@ -120,7 +120,7 @@ public virtual async Task StartAsync(CancellationToken cancellationToken) foreach (var key in existingJobKeys) { - if (jobKeys.Contains(key)) + if (JobKeys.Contains(key)) { continue; } @@ -151,6 +151,19 @@ public virtual async Task StopAsync(CancellationToken cancellationToken) } } + public async Task InterruptJobsAndShutdownAsync(CancellationToken cancellationToken = default) + { + if (_scheduler is not null) + { + foreach (var jobKey in JobKeys) + { + await _scheduler.Interrupt(jobKey, cancellationToken); + } + + await _scheduler.Shutdown(true, cancellationToken); + } + } + public virtual void Dispose() { } } diff --git a/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs b/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs new file mode 100644 index 000000000000..09e49ab65657 --- /dev/null +++ b/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Utilities; + +/// +/// Authorization attribute that restricts controller/action access to Development and QA environments only. +/// Returns 404 Not Found in all other environments. +/// +public class RequireLowerEnvironmentAttribute() : TypeFilterAttribute(typeof(LowerEnvironmentFilter)) +{ + private class LowerEnvironmentFilter(IWebHostEnvironment environment) : IAuthorizationFilter + { + public void OnAuthorization(AuthorizationFilterContext context) + { + if (!environment.IsDevelopment() && !environment.IsEnvironment("QA")) + { + context.Result = new NotFoundResult(); + } + } + } +} diff --git a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs new file mode 100644 index 000000000000..bb92c040cfa8 --- /dev/null +++ b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs @@ -0,0 +1,641 @@ +using Bit.Billing.Jobs; +using Bit.Billing.Services; +using Bit.Core; +using Bit.Core.Billing.Constants; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Quartz; +using Stripe; +using Xunit; + +namespace Bit.Billing.Test.Jobs; + +public class ReconcileAdditionalStorageJobTests +{ + private readonly IStripeFacade _stripeFacade; + private readonly ILogger _logger; + private readonly IFeatureService _featureService; + private readonly ReconcileAdditionalStorageJob _sut; + + public ReconcileAdditionalStorageJobTests() + { + _stripeFacade = Substitute.For(); + _logger = Substitute.For>(); + _featureService = Substitute.For(); + _sut = new ReconcileAdditionalStorageJob(_stripeFacade, _logger, _featureService); + } + + #region Feature Flag Tests + + [Fact] + public async Task Execute_FeatureFlagDisabled_SkipsProcessing() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob) + .Returns(false); + + // Act + await _sut.Execute(context); + + // Assert + _stripeFacade.DidNotReceiveWithAnyArgs().ListSubscriptionsAutoPagingAsync(); + } + + [Fact] + public async Task Execute_FeatureFlagEnabled_ProcessesSubscriptions() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob) + .Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode) + .Returns(false); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Empty()); + + // Act + await _sut.Execute(context); + + // Assert + _stripeFacade.Received(3).ListSubscriptionsAutoPagingAsync( + Arg.Is(o => o.Status == "active")); + } + + #endregion + + #region Dry Run Mode Tests + + [Fact] + public async Task Execute_DryRunMode_DoesNotUpdateSubscriptions() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); // Dry run ON + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_DryRunModeDisabled_UpdatesSubscriptions() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); // Dry run OFF + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => o.Items.Count == 1)); + } + + #endregion + + #region Price ID Processing Tests + + [Fact] + public async Task Execute_ProcessesAllThreePriceIds() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Empty()); + + // Act + await _sut.Execute(context); + + // Assert + _stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync( + Arg.Is(o => o.Price == "storage-gb-monthly")); + _stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync( + Arg.Is(o => o.Price == "storage-gb-annually")); + _stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync( + Arg.Is(o => o.Price == "personal-storage-gb-annually")); + } + + #endregion + + #region Already Processed Tests + + [Fact] + public async Task Execute_SubscriptionAlreadyProcessed_SkipsUpdate() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") + }; + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_SubscriptionWithInvalidProcessedDate_ProcessesSubscription() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.StorageReconciled2025] = "invalid-date" + }; + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any()); + } + + [Fact] + public async Task Execute_SubscriptionWithoutMetadata_ProcessesSubscription() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: null); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any()); + } + + #endregion + + #region Quantity Reduction Logic Tests + + [Fact] + public async Task Execute_QuantityGreaterThan4_ReducesBy4() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Quantity == 6 && + o.Items[0].Deleted != true)); + } + + [Fact] + public async Task Execute_QuantityEquals4_DeletesItem() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 4); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Deleted == true)); + } + + [Fact] + public async Task Execute_QuantityLessThan4_DeletesItem() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 2); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Deleted == true)); + } + + #endregion + + #region Update Options Tests + + [Fact] + public async Task Execute_UpdateOptions_SetsProrationBehaviorToAlwaysInvoice() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => o.ProrationBehavior == "always_invoice")); + } + + [Fact] + public async Task Execute_UpdateOptions_SetsReconciledMetadata() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => + o.Metadata.ContainsKey(StripeConstants.MetadataKeys.StorageReconciled2025) && + !string.IsNullOrEmpty(o.Metadata[StripeConstants.MetadataKeys.StorageReconciled2025]))); + } + + #endregion + + #region Subscription Filtering Tests + + [Fact] + public async Task Execute_SubscriptionWithNoItems_SkipsUpdate() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = new Subscription + { + Id = "sub_123", + Items = null + }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_SubscriptionWithDifferentPriceId_SkipsUpdate() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "different-price-id", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_NullSubscription_SkipsProcessing() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(null!)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + #endregion + + #region Multiple Subscriptions Tests + + [Fact] + public async Task Execute_MultipleSubscriptions_ProcessesAll() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + 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); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(callInfo => callInfo.Arg() switch + { + "sub_1" => subscription1, + "sub_2" => subscription2, + "sub_3" => subscription3, + _ => null + }); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_2", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any()); + } + + [Fact] + public async Task Execute_MixedSubscriptionsWithProcessed_OnlyProcessesUnprocessed() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var processedMetadata = new Dictionary + { + [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") + }; + + 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); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(callInfo => callInfo.Arg() switch + { + "sub_1" => subscription1, + "sub_3" => subscription3, + _ => null + }); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any()); + await _stripeFacade.DidNotReceive().UpdateSubscription("sub_2", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any()); + } + + #endregion + + #region Error Handling Tests + + [Fact] + public async Task Execute_UpdateFails_ContinuesProcessingOthers() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + 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); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); + + _stripeFacade.UpdateSubscription("sub_1", Arg.Any()) + .Returns(subscription1); + _stripeFacade.UpdateSubscription("sub_2", Arg.Any()) + .Throws(new Exception("Stripe API error")); + _stripeFacade.UpdateSubscription("sub_3", Arg.Any()) + .Returns(subscription3); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_2", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any()); + } + + [Fact] + public async Task Execute_UpdateFails_LogsError() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Throws(new Exception("Stripe API error")); + + // Act + await _sut.Execute(context); + + // Assert + _logger.Received().Log( + LogLevel.Error, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + #endregion + + #region Cancellation Tests + + [Fact] + public async Task Execute_CancellationRequested_LogsWarningAndExits() + { + // Arrange + var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + var context = CreateJobExecutionContext(cts.Token); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription1)); + + // Act + await _sut.Execute(context); + + // Assert - Should not process any subscriptions due to immediate cancellation + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null); + _logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + #endregion + + #region Helper Methods + + private static IJobExecutionContext CreateJobExecutionContext(CancellationToken cancellationToken = default) + { + var context = Substitute.For(); + context.CancellationToken.Returns(cancellationToken); + return context; + } + + private static Subscription CreateSubscription( + string id, + string priceId, + long? quantity = null, + Dictionary? metadata = null) + { + var price = new Price { Id = priceId }; + var item = new SubscriptionItem + { + Id = $"si_{id}", + Price = price, + Quantity = quantity ?? 0 + }; + + return new Subscription + { + Id = id, + Metadata = metadata, + Items = new StripeList + { + Data = new List { item } + } + }; + } + + #endregion +} + +internal static class AsyncEnumerable +{ + public static async IAsyncEnumerable Create(params T[] items) + { + foreach (var item in items) + { + yield return item; + } + await Task.CompletedTask; + } + + public static async IAsyncEnumerable Empty() + { + await Task.CompletedTask; + yield break; + } +} From 95a015c559a93fd3683ea0e256a1fb97d3efa5ea Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:27:34 -0600 Subject: [PATCH 02/10] format --- src/Billing/Controllers/JobsController.cs | 2 +- src/Billing/Jobs/ReconcileAdditionalStorageJob.cs | 8 ++++---- .../Jobs/ReconcileAdditionalStorageJobHostedService.cs | 2 +- src/Core/Utilities/RequireLowerEnvironmentAttribute.cs | 2 +- .../Jobs/ReconcileAdditionalStorageJobTests.cs | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Billing/Controllers/JobsController.cs b/src/Billing/Controllers/JobsController.cs index 02bdf3ca310c..3a93390237c3 100644 --- a/src/Billing/Controllers/JobsController.cs +++ b/src/Billing/Controllers/JobsController.cs @@ -1,4 +1,4 @@ -using Bit.Billing.Jobs; +using Bit.Billing.Jobs; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; using Quartz; diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs index 0896a8e31ba3..604e44ef26a0 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System.Globalization; +using System.Text.Json; using Bit.Billing.Services; using Bit.Core; using Bit.Core.Billing.Constants; @@ -6,7 +7,6 @@ using Bit.Core.Services; using Quartz; using Stripe; -using System.Text.Json; namespace Bit.Billing.Jobs; @@ -57,7 +57,7 @@ protected override async Task ExecuteJobAsync(IJobExecutionContext context) subscriptionsWithErrors, failures.Count > 0 ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" - : string.Empty + : string.Empty ); return; } @@ -126,7 +126,7 @@ protected override async Task ExecuteJobAsync(IJobExecutionContext context) subscriptionsWithErrors, failures.Count > 0 ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" - : string.Empty + : string.Empty ); } diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs index a64075bdc15f..0648b3872e32 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs @@ -1,4 +1,4 @@ -using Bit.Core.Jobs; +using Bit.Core.Jobs; using Bit.Core.Settings; using Quartz; diff --git a/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs b/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs index 09e49ab65657..a8208844a804 100644 --- a/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs +++ b/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Hosting; diff --git a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs index bb92c040cfa8..acc6f1c86779 100644 --- a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs +++ b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs @@ -1,4 +1,4 @@ -using Bit.Billing.Jobs; +using Bit.Billing.Jobs; using Bit.Billing.Services; using Bit.Core; using Bit.Core.Billing.Constants; From 0128eacdf4c37427b02c42b9a6dcb53e98561fd0 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:02:28 -0600 Subject: [PATCH 03/10] fixing cancellation --- src/Billing/Controllers/JobsController.cs | 8 ++- src/Billing/Jobs/JobsHostedService.cs | 52 ++++++++++++++++++- .../Jobs/ReconcileAdditionalStorageJob.cs | 30 ++--------- ...oncileAdditionalStorageJobHostedService.cs | 31 ----------- src/Billing/Startup.cs | 4 -- src/Core/Jobs/BaseJobsHostedService.cs | 21 ++------ 6 files changed, 61 insertions(+), 85 deletions(-) delete mode 100644 src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs diff --git a/src/Billing/Controllers/JobsController.cs b/src/Billing/Controllers/JobsController.cs index 3a93390237c3..62e47844fe88 100644 --- a/src/Billing/Controllers/JobsController.cs +++ b/src/Billing/Controllers/JobsController.cs @@ -1,7 +1,6 @@ using Bit.Billing.Jobs; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; -using Quartz; namespace Bit.Billing.Controllers; @@ -9,15 +8,14 @@ namespace Bit.Billing.Controllers; [SelfHosted(NotSelfHostedOnly = true)] [RequireLowerEnvironment] public class JobsController( - ReconcileAdditionalStorageJobHostedService jobsHostedService, - ISchedulerFactory schedulerFactory) : Controller + JobsHostedService jobsHostedService) : Controller { [HttpPost("run/{jobName}")] public async Task RunJobAsync(string jobName) { if (jobName == nameof(ReconcileAdditionalStorageJob)) { - await ReconcileAdditionalStorageJob.RunJobNowAsync(schedulerFactory); + await jobsHostedService.RunJobAdHocAsync(); return Ok(new { message = $"Job {jobName} scheduled successfully" }); } @@ -29,7 +27,7 @@ public async Task StopJobAsync(string jobName) { if (jobName == nameof(ReconcileAdditionalStorageJob)) { - await jobsHostedService.InterruptJobsAndShutdownAsync(); + await jobsHostedService.InterruptAdHocJobAsync(); return Ok(new { message = $"Job {jobName} stopped successfully" }); } diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index bd88f59bb00a..3e7a5722d0ae 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -8,9 +8,13 @@ public class JobsHostedService( GlobalSettings globalSettings, IServiceProvider serviceProvider, ILogger logger, - ILogger listenerLogger) + ILogger listenerLogger, + ISchedulerFactory schedulerFactory) : BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger) { + private List AdHocJobKeys { get; } = []; + private IScheduler? _adHocScheduler; + public override async Task StartAsync(CancellationToken cancellationToken) { Jobs = new List> @@ -25,5 +29,51 @@ public static void AddJobsServices(IServiceCollection services) { services.AddTransient(); services.AddTransient(); + // add this service as a singleton so we can inject it where needed + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + } + + public async Task InterruptAdHocJobAsync(CancellationToken cancellationToken = default) + { + if (_adHocScheduler == null) + { + throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job."); + } + + var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == nameof(T)); + if (jobKey == null) + { + throw new InvalidOperationException($"Cannot find job key: {nameof(T)}"); + } + await _adHocScheduler.Interrupt(jobKey, cancellationToken); + } + + public async Task RunJobAdHocAsync(CancellationToken cancellationToken = default) where T : class, IJob + { + _adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken); + + var jobKey = new JobKey(nameof(T)); + + var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken); + if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey))) + { + throw new InvalidOperationException($"Job {jobKey} is already running"); + } + + AdHocJobKeys.Add(jobKey); + + var job = JobBuilder.Create() + .WithIdentity(jobKey) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity(nameof(T)) + .StartNow() + .Build(); + + logger.LogInformation("Scheduling ad-hoc job with key: {JobKey}", jobKey); + + await _adHocScheduler.ScheduleJob(job, trigger, cancellationToken); } } diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs index 604e44ef26a0..2bd453b78577 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -67,6 +67,9 @@ protected override async Task ExecuteJobAsync(IJobExecutionContext context) continue; } + logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id); + subscriptionsFound++; + if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true) { if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed)) @@ -78,9 +81,6 @@ protected override async Task ExecuteJobAsync(IJobExecutionContext context) } } - logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id); - subscriptionsFound++; - var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId); if (updateOptions == null) @@ -198,28 +198,4 @@ public static ITrigger GetTrigger() .WithCronSchedule("0 0 16 * * ?") // 10am CST daily, assuming the pods execute in UTC time .Build(); } - - public static async Task RunJobNowAsync(ISchedulerFactory schedulerFactory) - { - var scheduler = await schedulerFactory.GetScheduler(); - - var jobKey = new JobKey(nameof(ReconcileAdditionalStorageJob)); - - var currentlyExecuting = await scheduler.GetCurrentlyExecutingJobs(); - if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey))) - { - throw new InvalidOperationException("Job is already running"); - } - - var job = JobBuilder.Create() - .WithIdentity(jobKey) - .Build(); - - var trigger = TriggerBuilder.Create() - .WithIdentity("ReconcileAdditionalStorageJob.RunNow") - .StartNow() - .Build(); - - await scheduler.ScheduleJob(job, trigger); - } } diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs deleted file mode 100644 index 0648b3872e32..000000000000 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJobHostedService.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Bit.Core.Jobs; -using Bit.Core.Settings; -using Quartz; - -namespace Bit.Billing.Jobs; - -public class ReconcileAdditionalStorageJobHostedService( - GlobalSettings globalSettings, - IServiceProvider serviceProvider, - ILogger logger, - ILogger listenerLogger) - : BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger) -{ - public override async Task StartAsync(CancellationToken cancellationToken) - { - Jobs = new List> - { - new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger()), - }; - - await base.StartAsync(cancellationToken); - } - - public static void AddJobsServices(IServiceCollection services) - { - services.AddTransient(); - services.AddSingleton(); - services.AddHostedService(sp => sp.GetRequiredService()); - } - -} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 82ea006ffa36..cdb9700ad575 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.Net.Http.Headers; -using Bit.Billing.Jobs; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Commercial.Core.Utilities; @@ -123,9 +122,6 @@ public void ConfigureServices(IServiceCollection services) Jobs.JobsHostedService.AddJobsServices(services); services.AddHostedService(); - // Specialized job service for reconcile storage job so it can be shutdown if needed - ReconcileAdditionalStorageJobHostedService.AddJobsServices(services); - // Swagger services.AddEndpointsApiExplorer(); services.AddSwaggerGen(); diff --git a/src/Core/Jobs/BaseJobsHostedService.cs b/src/Core/Jobs/BaseJobsHostedService.cs index 6dc4f5a22545..8b74052f8ff5 100644 --- a/src/Core/Jobs/BaseJobsHostedService.cs +++ b/src/Core/Jobs/BaseJobsHostedService.cs @@ -33,8 +33,7 @@ public BaseJobsHostedService( _globalSettings = globalSettings; } - protected IEnumerable>? Jobs { get; set; } - protected IList JobKeys { get; private set; } = new List(); + public IEnumerable>? Jobs { get; protected set; } public virtual async Task StartAsync(CancellationToken cancellationToken) { @@ -65,13 +64,14 @@ public virtual async Task StartAsync(CancellationToken cancellationToken) GroupMatcher.AnyGroup()); await _scheduler.Start(cancellationToken); + var jobKeys = new List(); var triggerKeys = new List(); if (Jobs != null) { foreach (var (job, trigger) in Jobs) { - JobKeys.Add(JobBuilder.Create(job) + jobKeys.Add(JobBuilder.Create(job) .WithIdentity(job.FullName!) .Build().Key); triggerKeys.Add(trigger.Key); @@ -120,7 +120,7 @@ public virtual async Task StartAsync(CancellationToken cancellationToken) foreach (var key in existingJobKeys) { - if (JobKeys.Contains(key)) + if (jobKeys.Contains(key)) { continue; } @@ -151,19 +151,6 @@ public virtual async Task StopAsync(CancellationToken cancellationToken) } } - public async Task InterruptJobsAndShutdownAsync(CancellationToken cancellationToken = default) - { - if (_scheduler is not null) - { - foreach (var jobKey in JobKeys) - { - await _scheduler.Interrupt(jobKey, cancellationToken); - } - - await _scheduler.Shutdown(true, cancellationToken); - } - } - public virtual void Dispose() { } } From a9e92cc8ceef6556aad1f519cc98f941570aaffb Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:15:26 -0600 Subject: [PATCH 04/10] final cleanup --- src/Billing/Jobs/JobsHostedService.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index 3e7a5722d0ae..0681ed29b644 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Jobs; +using Bit.Core.Exceptions; +using Bit.Core.Jobs; using Bit.Core.Settings; using Quartz; @@ -19,7 +20,8 @@ public override async Task StartAsync(CancellationToken cancellationToken) { Jobs = new List> { - new(typeof(AliveJob), AliveJob.GetTrigger()) + new(typeof(AliveJob), AliveJob.GetTrigger()), + new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger()) }; await base.StartAsync(cancellationToken); @@ -41,11 +43,13 @@ public async Task InterruptAdHocJobAsync(CancellationToken cancellationToken throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job."); } - var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == nameof(T)); + var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == typeof(T).ToString()); if (jobKey == null) { - throw new InvalidOperationException($"Cannot find job key: {nameof(T)}"); + throw new NotFoundException($"Cannot find job key: {typeof(T)}, not running?"); } + logger.LogInformation("CANCELLING ad-hoc job with key: {JobKey}", jobKey); + AdHocJobKeys.Remove(jobKey); await _adHocScheduler.Interrupt(jobKey, cancellationToken); } @@ -53,7 +57,7 @@ public async Task RunJobAdHocAsync(CancellationToken cancellationToken = defa { _adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken); - var jobKey = new JobKey(nameof(T)); + var jobKey = new JobKey(typeof(T).ToString()); var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken); if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey))) @@ -68,7 +72,7 @@ public async Task RunJobAdHocAsync(CancellationToken cancellationToken = defa .Build(); var trigger = TriggerBuilder.Create() - .WithIdentity(nameof(T)) + .WithIdentity(typeof(T).ToString()) .StartNow() .Build(); From 4075ab53bf47aec786e8f05a984973da6a40a608 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:17:05 -0600 Subject: [PATCH 05/10] wording --- src/Billing/Controllers/JobsController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Billing/Controllers/JobsController.cs b/src/Billing/Controllers/JobsController.cs index 62e47844fe88..6a5e8e55317f 100644 --- a/src/Billing/Controllers/JobsController.cs +++ b/src/Billing/Controllers/JobsController.cs @@ -28,7 +28,7 @@ public async Task StopJobAsync(string jobName) if (jobName == nameof(ReconcileAdditionalStorageJob)) { await jobsHostedService.InterruptAdHocJobAsync(); - return Ok(new { message = $"Job {jobName} stopped successfully" }); + return Ok(new { message = $"Job {jobName} queued for cancellation" }); } return BadRequest(new { error = $"Unknown job name: {jobName}" }); From 74d081292deec091fbe3ccf9e1a01c65fa33d3a6 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:35:13 -0600 Subject: [PATCH 06/10] tweaks --- src/Billing/Jobs/JobsHostedService.cs | 2 +- src/Billing/Jobs/ReconcileAdditionalStorageJob.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index 0681ed29b644..7f305e822624 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -36,7 +36,7 @@ public static void AddJobsServices(IServiceCollection services) services.AddHostedService(sp => sp.GetRequiredService()); } - public async Task InterruptAdHocJobAsync(CancellationToken cancellationToken = default) + public async Task InterruptAdHocJobAsync(CancellationToken cancellationToken = default) where T : class, IJob { if (_adHocScheduler == null) { diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs index 2bd453b78577..b8c8621b0022 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -195,7 +195,7 @@ public static ITrigger GetTrigger() return TriggerBuilder.Create() .WithIdentity("EveryMorningTrigger") .StartNow() - .WithCronSchedule("0 0 16 * * ?") // 10am CST daily, assuming the pods execute in UTC time + .WithCronSchedule("0 0 16 * * ?") // 10am CST daily; the pods execute in UTC time .Build(); } } From 2c7a84d849676867f06ec88eaac82c260d29a81f Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 21 Nov 2025 13:46:05 -0600 Subject: [PATCH 07/10] forgot registration --- src/Billing/Jobs/JobsHostedService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index 7f305e822624..25c57044da3a 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -31,6 +31,7 @@ public static void AddJobsServices(IServiceCollection services) { services.AddTransient(); services.AddTransient(); + services.AddTransient(); // add this service as a singleton so we can inject it where needed services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); From 9106728fa3159fd9eb388f700c68a1e44271f670 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:06:38 -0600 Subject: [PATCH 08/10] use stripe constants --- src/Billing/Jobs/ReconcileAdditionalStorageJob.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs index b8c8621b0022..aa98c9ec7e7a 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -41,7 +41,12 @@ protected override async Task ExecuteJobAsync(IJobExecutionContext context) foreach (var priceId in priceIds) { - var options = new SubscriptionListOptions { Limit = 100, Status = "active", Price = priceId }; + var options = new SubscriptionListOptions + { + Limit = 100, + Status = StripeConstants.SubscriptionStatus.Active, + Price = priceId + }; await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options)) { @@ -141,7 +146,7 @@ protected override async Task ExecuteJobAsync(IJobExecutionContext context) var updateOptions = new SubscriptionUpdateOptions { - ProrationBehavior = "always_invoice", + ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice, Metadata = new Dictionary { [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") From 2bbaf69d77295eaf3c2d2b4f5267aa40b615d79f Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:10:33 -0600 Subject: [PATCH 09/10] switching to the correct proration behavior --- src/Billing/Jobs/ReconcileAdditionalStorageJob.cs | 2 +- test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs index aa98c9ec7e7a..976c03a14ed6 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -146,7 +146,7 @@ protected override async Task ExecuteJobAsync(IJobExecutionContext context) var updateOptions = new SubscriptionUpdateOptions { - ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice, + ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, Metadata = new Dictionary { [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") diff --git a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs index acc6f1c86779..deb164f232d9 100644 --- a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs +++ b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs @@ -302,7 +302,7 @@ await _stripeFacade.Received(1).UpdateSubscription( #region Update Options Tests [Fact] - public async Task Execute_UpdateOptions_SetsProrationBehaviorToAlwaysInvoice() + public async Task Execute_UpdateOptions_SetsProrationBehaviorToCreateProrations() { // Arrange var context = CreateJobExecutionContext(); @@ -322,7 +322,7 @@ public async Task Execute_UpdateOptions_SetsProrationBehaviorToAlwaysInvoice() // Assert await _stripeFacade.Received(1).UpdateSubscription( "sub_123", - Arg.Is(o => o.ProrationBehavior == "always_invoice")); + Arg.Is(o => o.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations)); } [Fact] From 71dec86f27441de585c1359cbe1134dc55bb6baf Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:51:23 -0600 Subject: [PATCH 10/10] constant var --- src/Billing/Jobs/ReconcileAdditionalStorageJob.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs index 976c03a14ed6..d891fc18ffaf 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -18,6 +18,7 @@ public class ReconcileAdditionalStorageJob( 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; protected override async Task ExecuteJobAsync(IJobExecutionContext context) { @@ -161,9 +162,9 @@ protected override async Task ExecuteJobAsync(IJobExecutionContext context) hasUpdates = true; var currentQuantity = item.Quantity; - if (currentQuantity > 4) + if (currentQuantity > _storageGbToRemove) { - var newQuantity = currentQuantity - 4; + var newQuantity = currentQuantity - _storageGbToRemove; logger.LogInformation( "Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}", subscription.Id,