1
0
mirror of https://github.com/bitwarden/server synced 2026-02-14 07:23:26 +00:00

[PM-30908]Correct Premium subscription status handling (#6877)

* Implement the correct changes

* failing test has been removed

* Add unit testing and logs

* Resolve the pr comment on missed requirements

* fix the lint error

* resolve the build lint

* Fix the failing test

* Fix the failing test

* Add the IncompleteExpired status

* resolve the lint error

* Fix the build lint error

* Implement the IncompleteExpired flow
This commit is contained in:
cyprain-okeke
2026-02-13 18:56:26 +01:00
committed by GitHub
parent ea2b9b73c2
commit 84521a67c8
9 changed files with 544 additions and 32 deletions

View File

@@ -102,7 +102,7 @@ public class AccountBillingVNextController(
[BindNever] User user)
{
var subscription = await getBitwardenSubscriptionQuery.Run(user);
return TypedResults.Ok(subscription);
return subscription == null ? TypedResults.NotFound() : TypedResults.Ok(subscription);
}
[HttpPost("subscription/reinstate")]

View File

@@ -104,26 +104,47 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
var unpaidSubscriptions = subscriptions?.Data.Where(subscription =>
subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList();
if (unpaidSubscriptions == null || unpaidSubscriptions.Count == 0)
var incompleteSubscriptions = subscriptions?.Data.Where(subscription =>
subscription.Status == StripeConstants.SubscriptionStatus.Incomplete).ToList();
// Process unpaid subscriptions
if (unpaidSubscriptions != null && unpaidSubscriptions.Count > 0)
{
foreach (var subscription in unpaidSubscriptions)
{
await AttemptToPayOpenSubscriptionAsync(subscription);
}
}
// Process incomplete subscriptions - only if there's exactly one to avoid overcharging
if (incompleteSubscriptions == null || incompleteSubscriptions.Count == 0)
{
return;
}
foreach (var unpaidSubscription in unpaidSubscriptions)
{
await AttemptToPayOpenSubscriptionAsync(unpaidSubscription);
}
}
private async Task AttemptToPayOpenSubscriptionAsync(Subscription unpaidSubscription)
{
var latestInvoice = unpaidSubscription.LatestInvoice;
if (unpaidSubscription.LatestInvoice is null)
if (incompleteSubscriptions.Count > 1)
{
_logger.LogWarning(
"Attempted to pay unpaid subscription {SubscriptionId} but latest invoice didn't exist",
unpaidSubscription.Id);
"Customer {CustomerId} has {Count} incomplete subscriptions. Skipping automatic payment retry to avoid overcharging. Subscription IDs: {SubscriptionIds}",
customer.Id,
incompleteSubscriptions.Count,
string.Join(", ", incompleteSubscriptions.Select(s => s.Id)));
return;
}
// Exactly one incomplete subscription - safe to retry
await AttemptToPayOpenSubscriptionAsync(incompleteSubscriptions.First());
}
private async Task AttemptToPayOpenSubscriptionAsync(Subscription subscription)
{
var latestInvoice = subscription.LatestInvoice;
if (subscription.LatestInvoice is null)
{
_logger.LogWarning(
"Attempted to pay subscription {SubscriptionId} with status {Status} but latest invoice didn't exist",
subscription.Id, subscription.Status);
return;
}
@@ -131,8 +152,8 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
if (latestInvoice.Status != StripeInvoiceStatus.Open)
{
_logger.LogWarning(
"Attempted to pay unpaid subscription {SubscriptionId} but latest invoice wasn't \"open\"",
unpaidSubscription.Id);
"Attempted to pay subscription {SubscriptionId} with status {Status} but latest invoice wasn't \"open\"",
subscription.Id, subscription.Status);
return;
}
@@ -144,8 +165,8 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
catch (Exception e)
{
_logger.LogError(e,
"Attempted to pay open invoice {InvoiceId} on unpaid subscription {SubscriptionId} but encountered an error",
latestInvoice.Id, unpaidSubscription.Id);
"Attempted to pay open invoice {InvoiceId} on subscription {SubscriptionId} with status {Status} but encountered an error",
latestInvoice.Id, subscription.Id, subscription.Status);
throw;
}
}

View File

@@ -69,7 +69,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
var currentPeriodEnd = subscription.GetCurrentPeriodEnd();
if (SubscriptionWentUnpaid(parsedEvent, subscription))
if (SubscriptionWentUnpaid(parsedEvent, subscription) ||
SubscriptionWentIncompleteExpired(parsedEvent, subscription))
{
await DisableSubscriberAsync(subscriberId, currentPeriodEnd);
await SetSubscriptionToCancelAsync(subscription);
@@ -111,6 +112,18 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle
};
private static bool SubscriptionWentIncompleteExpired(
Event parsedEvent,
Subscription currentSubscription) =>
parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() is Subscription
{
Status: SubscriptionStatus.Incomplete
} && currentSubscription is
{
Status: SubscriptionStatus.IncompleteExpired,
LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle
};
private static bool SubscriptionBecameActive(
Event parsedEvent,
Subscription currentSubscription) =>

View File

@@ -3,6 +3,7 @@ using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
@@ -143,6 +144,24 @@ public class UpdatePaymentMethodCommand(
await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata });
}
// If the subscriber has an incomplete subscription, pay the invoice with the new PayPal payment method
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId);
if (subscription.Status == StripeConstants.SubscriptionStatus.Incomplete)
{
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceUpdateOptions
{
AutoAdvance = false,
Expand = ["customer"]
});
await braintreeService.PayInvoice(new UserId(subscriber.Id), invoice);
}
}
var payPalAccount = braintreeCustomer.DefaultPaymentMethod as PayPalAccount;
return MaskedPaymentMethod.From(payPalAccount!);

View File

@@ -72,7 +72,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
BillingAddress billingAddress,
short additionalStorageGb) => HandleAsync<None>(async () =>
{
if (user.Premium)
// A "terminal" subscription is one that has ended and cannot be renewed/reactivated.
// These are: 'canceled' (user canceled) and 'incomplete_expired' (payment failed and time expired).
// We allow users with terminal subscriptions to create a new subscription even if user.Premium is still true,
// enabling the resubscribe workflow without requiring Premium status to be cleared first.
var hasTerminalSubscription = await HasTerminalSubscriptionAsync(user);
if (user.Premium && !hasTerminalSubscription)
{
return new BadRequest("Already a premium user.");
}
@@ -98,8 +104,11 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
* purchased account credit but chose to use a tokenizable payment method to pay for the subscription. In this case,
* we need to add the payment method to their customer first. If the incoming payment method is account credit,
* we can just go straight to fetching the customer since there's no payment method to apply.
*
* Additionally, if this is a resubscribe scenario with a tokenized payment method, we should update the payment method
* to ensure the new payment method is used instead of the old one.
*/
else if (paymentMethod.IsTokenized && !await hasPaymentMethodQuery.Run(user))
else if (paymentMethod.IsTokenized && (!await hasPaymentMethodQuery.Run(user) || hasTerminalSubscription))
{
await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress);
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
@@ -122,7 +131,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
case { Type: TokenizablePaymentMethodType.PayPal }
when subscription.Status == SubscriptionStatus.Incomplete:
case { Type: not TokenizablePaymentMethodType.PayPal }
when subscription.Status == SubscriptionStatus.Active:
when subscription.Status is SubscriptionStatus.Active or SubscriptionStatus.Incomplete:
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
@@ -369,4 +378,28 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
return subscription;
}
private async Task<bool> HasTerminalSubscriptionAsync(User user)
{
if (string.IsNullOrEmpty(user.GatewaySubscriptionId))
{
return false;
}
try
{
var existingSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
return existingSubscription.Status is
SubscriptionStatus.Canceled or
SubscriptionStatus.IncompleteExpired;
}
catch (Exception ex)
{
// Subscription doesn't exist in Stripe or can't be fetched (e.g., network issues, invalid ID)
// Log the issue but proceed with subscription creation to avoid blocking legitimate resubscribe attempts
_logger.LogWarning(ex, "Unable to fetch existing subscription {SubscriptionId} for user {UserId}. Proceeding with subscription creation",
user.GatewaySubscriptionId, user.Id);
return false;
}
}
}

View File

@@ -31,7 +31,7 @@ public interface IGetBitwardenSubscriptionQuery
/// Currently only supports <see cref="User"/> subscribers. Future versions will support all
/// <see cref="ISubscriber"/> types (User and Organization).
/// </remarks>
Task<BitwardenSubscription> Run(User user);
Task<BitwardenSubscription?> Run(User user);
}
public class GetBitwardenSubscriptionQuery(
@@ -39,8 +39,13 @@ public class GetBitwardenSubscriptionQuery(
IPricingClient pricingClient,
IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery
{
public async Task<BitwardenSubscription> Run(User user)
public async Task<BitwardenSubscription?> Run(User user)
{
if (string.IsNullOrEmpty(user.GatewaySubscriptionId))
{
return null;
}
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
{
Expand =

View File

@@ -315,14 +315,14 @@ public class SubscriptionUpdatedHandlerTests
}
[Fact]
public async Task HandleAsync_ProviderSubscription_WithIncompleteExpiredStatus_DoesNotDisableProvider()
public async Task HandleAsync_IncompleteToIncompleteExpiredTransition_DisablesProviderAndSetsCancellation()
{
// Arrange
var providerId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
// Previous status that doesn't trigger enable/disable logic
// Previous status was Incomplete - this is the valid transition for IncompleteExpired
var previousSubscription = new Subscription
{
Id = subscriptionId,
@@ -341,7 +341,7 @@ public class SubscriptionUpdatedHandlerTests
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = "renewal" }
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCreate }
};
var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true };
@@ -364,10 +364,142 @@ public class SubscriptionUpdatedHandlerTests
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - IncompleteExpired status is not handled by the new logic
Assert.True(provider.Enabled);
await _providerService.DidNotReceive().UpdateAsync(Arg.Any<Provider>());
await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
// Assert - Incomplete to IncompleteExpired should trigger disable and cancellation
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
await _stripeFacade.Received(1).UpdateSubscription(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
options.ProrationBehavior == ProrationBehavior.None &&
options.CancellationDetails != null &&
options.CancellationDetails.Comment != null));
}
[Fact]
public async Task HandleAsync_IncompleteToIncompleteExpiredUserSubscription_DisablesPremiumAndSetsCancellation()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Incomplete
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.IncompleteExpired,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCreate }
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userService.Received(1).DisablePremiumAsync(userId, currentPeriodEnd);
await _stripeFacade.Received(1).UpdateSubscription(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
options.ProrationBehavior == ProrationBehavior.None &&
options.CancellationDetails != null &&
options.CancellationDetails.Comment != null));
}
[Fact]
public async Task HandleAsync_IncompleteToIncompleteExpiredOrganizationSubscription_DisablesOrganizationAndSetsCancellation()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Incomplete
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.IncompleteExpired,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCreate }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationDisableCommand.Received(1).DisableAsync(organizationId, currentPeriodEnd);
await _pushNotificationAdapter.Received(1).NotifyEnabledChangedAsync(organization);
await _stripeFacade.Received(1).UpdateSubscription(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
options.ProrationBehavior == ProrationBehavior.None &&
options.CancellationDetails != null &&
options.CancellationDetails.Comment != null));
}
[Fact]
@@ -470,6 +602,9 @@ public class SubscriptionUpdatedHandlerTests
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
// Act
await _sut.HandleAsync(parsedEvent);
@@ -484,6 +619,10 @@ public class SubscriptionUpdatedHandlerTests
options.ProrationBehavior == ProrationBehavior.None &&
options.CancellationDetails != null &&
options.CancellationDetails.Comment != null));
await _stripeFacade.DidNotReceive()
.CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
await _stripeFacade.DidNotReceive()
.ListInvoices(Arg.Any<InvoiceListOptions>());
}
[Fact]
@@ -527,6 +666,9 @@ public class SubscriptionUpdatedHandlerTests
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
// Act
await _sut.HandleAsync(parsedEvent);
@@ -534,6 +676,10 @@ public class SubscriptionUpdatedHandlerTests
await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _userService.Received(1).UpdatePremiumExpirationAsync(userId, currentPeriodEnd);
await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _stripeFacade.DidNotReceive()
.CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
await _stripeFacade.DidNotReceive()
.ListInvoices(Arg.Any<InvoiceListOptions>());
}
[Fact]

View File

@@ -812,4 +812,255 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
await _userService.Received(1).SaveUserAsync(user);
}
[Theory, BitAutoData]
public async Task Run_UserWithCanceledSubscription_AllowsResubscribe(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = true; // User still has Premium flag set
user.GatewayCustomerId = "existing_customer_123";
user.GatewaySubscriptionId = "sub_canceled_123";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "card_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var existingCanceledSubscription = Substitute.For<StripeSubscription>();
existingCanceledSubscription.Id = "sub_canceled_123";
existingCanceledSubscription.Status = "canceled"; // Terminal status
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "existing_customer_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var newSubscription = Substitute.For<StripeSubscription>();
newSubscription.Id = "sub_new_123";
newSubscription.Status = "active";
newSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingCanceledSubscription);
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true);
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0); // Should succeed, not return "Already a premium user"
Assert.True(user.Premium);
Assert.Equal(newSubscription.Id, user.GatewaySubscriptionId);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.Received(1).SaveUserAsync(user);
}
[Theory, BitAutoData]
public async Task Run_UserWithIncompleteExpiredSubscription_AllowsResubscribe(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = true; // User still has Premium flag set
user.GatewayCustomerId = "existing_customer_123";
user.GatewaySubscriptionId = "sub_incomplete_expired_123";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "card_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var existingExpiredSubscription = Substitute.For<StripeSubscription>();
existingExpiredSubscription.Id = "sub_incomplete_expired_123";
existingExpiredSubscription.Status = "incomplete_expired"; // Terminal status
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "existing_customer_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var newSubscription = Substitute.For<StripeSubscription>();
newSubscription.Id = "sub_new_123";
newSubscription.Status = "active";
newSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingExpiredSubscription);
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true);
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0); // Should succeed, not return "Already a premium user"
Assert.True(user.Premium);
Assert.Equal(newSubscription.Id, user.GatewaySubscriptionId);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.Received(1).SaveUserAsync(user);
}
[Theory, BitAutoData]
public async Task Run_UserWithActiveSubscription_PremiumTrue_ReturnsBadRequest(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_active_123";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
var existingActiveSubscription = Substitute.For<StripeSubscription>();
existingActiveSubscription.Id = "sub_active_123";
existingActiveSubscription.Status = "active"; // NOT a terminal status
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingActiveSubscription);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("Already a premium user.", badRequest.Response);
// Verify no subscription creation was attempted
await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
public async Task Run_SubscriptionFetchThrows_ProceedsWithCreation(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = "existing_customer_123";
user.GatewaySubscriptionId = "sub_nonexistent_123";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "card_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
// Simulate Stripe exception when fetching subscription (e.g., subscription doesn't exist)
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId)
.Returns<StripeSubscription>(_ => throw new Stripe.StripeException("Subscription not found"));
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "existing_customer_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var newSubscription = Substitute.For<StripeSubscription>();
newSubscription.Id = "sub_new_123";
newSubscription.Status = "active";
newSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true);
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert - Should proceed successfully despite the exception
Assert.True(result.IsT0);
Assert.True(user.Premium);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.Received(1).SaveUserAsync(user);
}
[Theory, BitAutoData]
public async Task Run_ResubscribeWithTerminalSubscription_UpdatesPaymentMethod(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewayCustomerId = "existing_customer_123";
user.GatewaySubscriptionId = "sub_canceled_123";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "new_card_token_456";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var existingCanceledSubscription = Substitute.For<StripeSubscription>();
existingCanceledSubscription.Id = "sub_canceled_123";
existingCanceledSubscription.Status = "canceled"; // Terminal status
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "existing_customer_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var newSubscription = Substitute.For<StripeSubscription>();
newSubscription.Id = "sub_new_123";
newSubscription.Status = "active";
newSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
MaskedPaymentMethod mockMaskedPaymentMethod = new MaskedCard
{
Brand = "visa",
Last4 = "4567",
Expiration = "12/2026"
};
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingCanceledSubscription);
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true); // Has old payment method
_updatePaymentMethodCommand.Run(Arg.Any<User>(), Arg.Any<TokenizedPaymentMethod>(), Arg.Any<BillingAddress>())
.Returns(mockMaskedPaymentMethod);
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0);
// Verify payment method was updated because of terminal subscription
await _updatePaymentMethodCommand.Received(1).Run(user, paymentMethod, billingAddress);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.Received(1).SaveUserAsync(user);
}
}

View File

@@ -31,6 +31,30 @@ public class GetBitwardenSubscriptionQueryTests
_stripeAdapter);
}
[Fact]
public async Task Run_UserWithoutGatewaySubscriptionId_ReturnsNull()
{
var user = CreateUser();
user.GatewaySubscriptionId = null;
var result = await _query.Run(user);
Assert.Null(result);
await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
}
[Fact]
public async Task Run_UserWithEmptyGatewaySubscriptionId_ReturnsNull()
{
var user = CreateUser();
user.GatewaySubscriptionId = string.Empty;
var result = await _query.Run(user);
Assert.Null(result);
await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
}
[Fact]
public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension()
{