From 101ff9d6ed09d5c5dcd083b8450a88e0360e3adc Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:10:13 -0600 Subject: [PATCH] [PM-28423] Add `latest_invoice` expansion / logging to `SubscriptionCancellationJob` (#6603) * Added latest_invoice expansion / logging to cancellation job * Run dotnet format * Claude feedback * Run dotnet format --- .../Jobs/SubscriptionCancellationJob.cs | 30 +- src/Core/Billing/Constants/StripeConstants.cs | 6 + .../Jobs/SubscriptionCancellationJobTests.cs | 388 ++++++++++++++++++ 3 files changed, 416 insertions(+), 8 deletions(-) create mode 100644 test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs diff --git a/src/Billing/Jobs/SubscriptionCancellationJob.cs b/src/Billing/Jobs/SubscriptionCancellationJob.cs index 69b7bc876d..60b671df3d 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 class SubscriptionCancellationJob( 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: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle } + }) + { + 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 class SubscriptionCancellationJob( 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 class SubscriptionCancellationJob( 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/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index c062351a91..dc128127ae 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -12,6 +12,12 @@ public static class StripeConstants public const string UnrecognizedLocation = "unrecognized_location"; } + public static class BillingReasons + { + public const string SubscriptionCreate = "subscription_create"; + public const string SubscriptionCycle = "subscription_cycle"; + } + public static class CollectionMethod { public const string ChargeAutomatically = "charge_automatically"; diff --git a/test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs b/test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs new file mode 100644 index 0000000000..03bf24f7ff --- /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; + } +}