diff --git a/src/Billing/Jobs/SubscriptionCancellationJob.cs b/src/Billing/Jobs/SubscriptionCancellationJob.cs index 69b7bc876dfe..8d68be6188ad 100644 --- a/src/Billing/Jobs/SubscriptionCancellationJob.cs +++ b/src/Billing/Jobs/SubscriptionCancellationJob.cs @@ -1,16 +1,17 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Billing.Services; +using Bit.Billing.Services; +using Bit.Core.Billing.Constants; using Bit.Core.Repositories; using Quartz; using Stripe; namespace Bit.Billing.Jobs; +using static StripeConstants; + public class SubscriptionCancellationJob( IStripeFacade stripeFacade, - IOrganizationRepository organizationRepository) + IOrganizationRepository organizationRepository, + ILogger logger) : IJob { public async Task Execute(IJobExecutionContext context) @@ -21,20 +22,31 @@ public async Task Execute(IJobExecutionContext context) var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization == null || organization.Enabled) { + logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because organization is either null or enabled", nameof(SubscriptionCancellationJob), subscriptionId); // Organization was deleted or re-enabled by CS, skip cancellation return; } - var subscription = await stripeFacade.GetSubscription(subscriptionId); - if (subscription?.Status != "unpaid" || - subscription.LatestInvoice?.BillingReason is not ("subscription_cycle" or "subscription_create")) + var subscription = await stripeFacade.GetSubscription(subscriptionId, new SubscriptionGetOptions + { + Expand = ["latest_invoice"] + }); + + if (subscription is not + { + Status: SubscriptionStatus.Unpaid, + LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" + }) { + logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because subscription is not unpaid or does not have a cancellable billing reason", nameof(SubscriptionCancellationJob), subscriptionId); return; } // Cancel the subscription await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions()); + logger.LogInformation("{Job} cancelled subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), subscriptionId); + // Void any open invoices var options = new InvoiceListOptions { @@ -46,6 +58,7 @@ public async Task Execute(IJobExecutionContext context) foreach (var invoice in invoices) { await stripeFacade.VoidInvoice(invoice.Id); + logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId); } while (invoices.HasMore) @@ -55,6 +68,7 @@ public async Task Execute(IJobExecutionContext context) foreach (var invoice in invoices) { await stripeFacade.VoidInvoice(invoice.Id); + logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId); } } } diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 6db0cb63732e..5709769cb2ef 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -225,7 +225,7 @@ private async Task AlignOrganizationSubscriptionConcernsAsync( new SubscriptionItemOptions { Id = passwordManagerItem.Id, - Price = families.PasswordManager.StripePlanId + Price = families.PasswordManager.StripePlanId } ], ProrationBehavior = ProrationBehavior.None diff --git a/test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs b/test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs new file mode 100644 index 000000000000..03bf24f7ff1f --- /dev/null +++ b/test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs @@ -0,0 +1,388 @@ +using Bit.Billing.Constants; +using Bit.Billing.Jobs; +using Bit.Billing.Services; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Quartz; +using Stripe; +using Xunit; + +namespace Bit.Billing.Test.Jobs; + +public class SubscriptionCancellationJobTests +{ + private readonly IStripeFacade _stripeFacade; + private readonly IOrganizationRepository _organizationRepository; + private readonly SubscriptionCancellationJob _sut; + + public SubscriptionCancellationJobTests() + { + _stripeFacade = Substitute.For(); + _organizationRepository = Substitute.For(); + _sut = new SubscriptionCancellationJob(_stripeFacade, _organizationRepository, Substitute.For>()); + } + + [Fact] + public async Task Execute_OrganizationIsNull_SkipsCancellation() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + _organizationRepository.GetByIdAsync(organizationId).Returns((Organization)null); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Execute_OrganizationIsEnabled_SkipsCancellation() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = true + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Execute_SubscriptionStatusIsNotUnpaid_SkipsCancellation() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Active, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any()); + } + + [Fact] + public async Task Execute_BillingReasonIsInvalid_SkipsCancellation() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "manual" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any()); + } + + [Fact] + public async Task Execute_ValidConditions_CancelsSubscriptionAndVoidsInvoices() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + var invoices = new StripeList + { + Data = + [ + new Invoice { Id = "inv_1" }, + new Invoice { Id = "inv_2" } + ], + HasMore = false + }; + _stripeFacade.ListInvoices(Arg.Any()).Returns(invoices); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.Received(1).VoidInvoice("inv_1"); + await _stripeFacade.Received(1).VoidInvoice("inv_2"); + } + + [Fact] + public async Task Execute_WithSubscriptionCreateBillingReason_CancelsSubscription() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_create" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + var invoices = new StripeList + { + Data = [], + HasMore = false + }; + _stripeFacade.ListInvoices(Arg.Any()).Returns(invoices); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any()); + } + + [Fact] + public async Task Execute_NoOpenInvoices_CancelsSubscriptionOnly() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + var invoices = new StripeList + { + Data = [], + HasMore = false + }; + _stripeFacade.ListInvoices(Arg.Any()).Returns(invoices); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.DidNotReceiveWithAnyArgs().VoidInvoice(Arg.Any()); + } + + [Fact] + public async Task Execute_WithPagination_VoidsAllInvoices() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + // First page of invoices + var firstPage = new StripeList + { + Data = + [ + new Invoice { Id = "inv_1" }, + new Invoice { Id = "inv_2" } + ], + HasMore = true + }; + + // Second page of invoices + var secondPage = new StripeList + { + Data = + [ + new Invoice { Id = "inv_3" }, + new Invoice { Id = "inv_4" } + ], + HasMore = false + }; + + _stripeFacade.ListInvoices(Arg.Is(o => o.StartingAfter == null)) + .Returns(firstPage); + _stripeFacade.ListInvoices(Arg.Is(o => o.StartingAfter == "inv_2")) + .Returns(secondPage); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.Received(1).VoidInvoice("inv_1"); + await _stripeFacade.Received(1).VoidInvoice("inv_2"); + await _stripeFacade.Received(1).VoidInvoice("inv_3"); + await _stripeFacade.Received(1).VoidInvoice("inv_4"); + await _stripeFacade.Received(2).ListInvoices(Arg.Any()); + } + + [Fact] + public async Task Execute_ListInvoicesCalledWithCorrectOptions() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + var invoices = new StripeList + { + Data = [], + HasMore = false + }; + _stripeFacade.ListInvoices(Arg.Any()).Returns(invoices); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))); + await _stripeFacade.Received(1).ListInvoices(Arg.Is(o => + o.Status == "open" && + o.Subscription == subscriptionId && + o.Limit == 100)); + } + + private static IJobExecutionContext CreateJobExecutionContext(string subscriptionId, Guid organizationId) + { + var context = Substitute.For(); + var jobDataMap = new JobDataMap + { + { "subscriptionId", subscriptionId }, + { "organizationId", organizationId.ToString() } + }; + context.MergedJobDataMap.Returns(jobDataMap); + return context; + } +}