1
0
mirror of https://github.com/bitwarden/server synced 2025-12-10 13:23:27 +00:00

[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
This commit is contained in:
Alex Morask
2025-12-04 13:10:13 -06:00
committed by GitHub
parent d88fff4262
commit 101ff9d6ed
3 changed files with 416 additions and 8 deletions

View File

@@ -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<SubscriptionCancellationJob> 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);
}
}
}

View File

@@ -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";

View File

@@ -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<IStripeFacade>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_sut = new SubscriptionCancellationJob(_stripeFacade, _organizationRepository, Substitute.For<ILogger<SubscriptionCancellationJob>>());
}
[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<string>(), Arg.Any<SubscriptionGetOptions>());
await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
}
[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<string>(), Arg.Any<SubscriptionGetOptions>());
await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
}
[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<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
}
[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<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
}
[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<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
var invoices = new StripeList<Invoice>
{
Data =
[
new Invoice { Id = "inv_1" },
new Invoice { Id = "inv_2" }
],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
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<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
var invoices = new StripeList<Invoice>
{
Data = [],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
}
[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<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
var invoices = new StripeList<Invoice>
{
Data = [],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
await _stripeFacade.DidNotReceiveWithAnyArgs().VoidInvoice(Arg.Any<string>());
}
[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<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
// First page of invoices
var firstPage = new StripeList<Invoice>
{
Data =
[
new Invoice { Id = "inv_1" },
new Invoice { Id = "inv_2" }
],
HasMore = true
};
// Second page of invoices
var secondPage = new StripeList<Invoice>
{
Data =
[
new Invoice { Id = "inv_3" },
new Invoice { Id = "inv_4" }
],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Is<InvoiceListOptions>(o => o.StartingAfter == null))
.Returns(firstPage);
_stripeFacade.ListInvoices(Arg.Is<InvoiceListOptions>(o => o.StartingAfter == "inv_2"))
.Returns(secondPage);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
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<InvoiceListOptions>());
}
[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<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
var invoices = new StripeList<Invoice>
{
Data = [],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")));
await _stripeFacade.Received(1).ListInvoices(Arg.Is<InvoiceListOptions>(o =>
o.Status == "open" &&
o.Subscription == subscriptionId &&
o.Limit == 100));
}
private static IJobExecutionContext CreateJobExecutionContext(string subscriptionId, Guid organizationId)
{
var context = Substitute.For<IJobExecutionContext>();
var jobDataMap = new JobDataMap
{
{ "subscriptionId", subscriptionId },
{ "organizationId", organizationId.ToString() }
};
context.MergedJobDataMap.Returns(jobDataMap);
return context;
}
}