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:
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user