diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index d91ca21520..a6e702c662 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -32,5 +32,6 @@ public class JobsHostedService : BaseJobsHostedService public static void AddJobsServices(IServiceCollection services) { services.AddTransient(); + services.AddTransient(); } } diff --git a/src/Billing/Jobs/SubscriptionCancellationJob.cs b/src/Billing/Jobs/SubscriptionCancellationJob.cs new file mode 100644 index 0000000000..c46581272e --- /dev/null +++ b/src/Billing/Jobs/SubscriptionCancellationJob.cs @@ -0,0 +1,58 @@ +using Bit.Billing.Services; +using Bit.Core.Repositories; +using Quartz; +using Stripe; + +namespace Bit.Billing.Jobs; + +public class SubscriptionCancellationJob( + IStripeFacade stripeFacade, + IOrganizationRepository organizationRepository) + : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var subscriptionId = context.MergedJobDataMap.GetString("subscriptionId"); + var organizationId = new Guid(context.MergedJobDataMap.GetString("organizationId") ?? string.Empty); + + var organization = await organizationRepository.GetByIdAsync(organizationId); + if (organization == null || organization.Enabled) + { + // Organization was deleted or re-enabled by CS, skip cancellation + return; + } + + var subscription = await stripeFacade.GetSubscription(subscriptionId); + if (subscription?.Status != "unpaid") + { + // Subscription is no longer unpaid, skip cancellation + return; + } + + // Cancel the subscription + await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions()); + + // Void any open invoices + var options = new InvoiceListOptions + { + Status = "open", + Subscription = subscriptionId, + Limit = 100 + }; + var invoices = await stripeFacade.ListInvoices(options); + foreach (var invoice in invoices) + { + await stripeFacade.VoidInvoice(invoice.Id); + } + + while (invoices.HasMore) + { + options.StartingAfter = invoices.Data.Last().Id; + invoices = await stripeFacade.ListInvoices(options); + foreach (var invoice in invoices) + { + await stripeFacade.VoidInvoice(invoice.Id); + } + } + } +} diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 6b4fef43d1..ea277a6307 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,9 +1,12 @@ using Bit.Billing.Constants; +using Bit.Billing.Jobs; +using Bit.Core; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; +using Quartz; using Stripe; using Event = Stripe.Event; @@ -19,6 +22,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IUserService _userService; private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; + private readonly ISchedulerFactory _schedulerFactory; + private readonly IFeatureService _featureService; public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, @@ -28,7 +33,9 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, IUserService userService, IPushNotificationService pushNotificationService, - IOrganizationRepository organizationRepository) + IOrganizationRepository organizationRepository, + ISchedulerFactory schedulerFactory, + IFeatureService featureService) { _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; @@ -38,6 +45,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler _userService = userService; _pushNotificationService = pushNotificationService; _organizationRepository = organizationRepository; + _schedulerFactory = schedulerFactory; + _featureService = featureService; } /// @@ -55,6 +64,10 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler when organizationId.HasValue: { await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd); + if (subscription.Status == StripeSubscriptionStatus.Unpaid) + { + await ScheduleCancellationJobAsync(subscription.Id, organizationId.Value); + } break; } case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired: @@ -183,4 +196,27 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler await _stripeFacade.DeleteSubscriptionDiscount(subscription.Id); } } + + private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId) + { + var isResellerManagedOrgAlertEnabled = _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert); + + if (isResellerManagedOrgAlertEnabled) + { + var scheduler = await _schedulerFactory.GetScheduler(); + + var job = JobBuilder.Create() + .WithIdentity($"cancel-sub-{subscriptionId}", "subscription-cancellations") + .UsingJobData("subscriptionId", subscriptionId) + .UsingJobData("organizationId", organizationId.ToString()) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity($"cancel-trigger-{subscriptionId}", "subscription-cancellations") + .StartAt(DateTimeOffset.UtcNow.AddDays(7)) + .Build(); + + await scheduler.ScheduleJob(job, trigger); + } + } } diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index e3547d943b..2d2f109e77 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -9,6 +9,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; +using Quartz; using Stripe; namespace Bit.Billing; @@ -101,6 +102,13 @@ public class Startup services.AddScoped(); services.AddScoped(); + // Add Quartz services first + services.AddQuartz(q => + { + q.UseMicrosoftDependencyInjectionJobFactory(); + }); + services.AddQuartzHostedService(); + // Jobs service Jobs.JobsHostedService.AddJobsServices(services); services.AddHostedService(); diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index ddd9fc26bb..7a5f7e2543 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -67,6 +67,9 @@ + + +