mirror of
https://github.com/bitwarden/server.git
synced 2025-12-10 17:45:21 -06:00
* Added latest_invoice expansion / logging to cancellation job * Run dotnet format * Claude feedback * Run dotnet format
389 lines
13 KiB
C#
389 lines
13 KiB
C#
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;
|
|
}
|
|
}
|