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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
388
test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs
Normal file
388
test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user