diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 241e595333..579804df0f 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -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")] diff --git a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs index 548a41879c..ca2d61e8c3 100644 --- a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs @@ -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; } } diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 4507d9e308..e4710f7dce 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -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() 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) => diff --git a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs index 5734babc31..225954fb46 100644 --- a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs @@ -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!); diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 764406ee56..7dc9067635 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -72,7 +72,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand( BillingAddress billingAddress, short additionalStorageGb) => HandleAsync(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 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; + } + } } diff --git a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs index 51c51bd7b2..f1ebcfb986 100644 --- a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs +++ b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs @@ -31,7 +31,7 @@ public interface IGetBitwardenSubscriptionQuery /// Currently only supports subscribers. Future versions will support all /// types (User and Organization). /// - Task Run(User user); + Task Run(User user); } public class GetBitwardenSubscriptionQuery( @@ -39,8 +39,13 @@ public class GetBitwardenSubscriptionQuery( IPricingClient pricingClient, IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery { - public async Task Run(User user) + public async Task Run(User user) { + if (string.IsNullOrEmpty(user.GatewaySubscriptionId)) + { + return null; + } + var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 1807050b31..9517802f13 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -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 { { "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()); - await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); + // 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(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 { { "userId", userId.ToString() } }, + Items = new StripeList + { + 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(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userService.Received(1).DisablePremiumAsync(userId, currentPeriodEnd); + await _stripeFacade.Received(1).UpdateSubscription( + subscriptionId, + Arg.Is(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 + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + } + ] + }, + Metadata = new Dictionary { { "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(), Arg.Any(), Arg.Any>()) + .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(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(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(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(), Arg.Any()); + await _stripeFacade.DidNotReceive() + .ListInvoices(Arg.Any()); } [Fact] @@ -527,6 +666,9 @@ public class SubscriptionUpdatedHandlerTests _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + // Act await _sut.HandleAsync(parsedEvent); @@ -534,6 +676,10 @@ public class SubscriptionUpdatedHandlerTests await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any(), Arg.Any()); await _userService.Received(1).UpdatePremiumExpirationAsync(userId, currentPeriodEnd); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceive() + .CancelSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceive() + .ListInvoices(Arg.Any()); } [Fact] diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index da287dc02b..0e38850111 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -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(); + existingCanceledSubscription.Id = "sub_canceled_123"; + existingCanceledSubscription.Status = "canceled"; // Terminal status + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var newSubscription = Substitute.For(); + newSubscription.Id = "sub_new_123"; + newSubscription.Status = "active"; + newSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingCanceledSubscription); + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).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()); + 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(); + existingExpiredSubscription.Id = "sub_incomplete_expired_123"; + existingExpiredSubscription.Status = "incomplete_expired"; // Terminal status + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var newSubscription = Substitute.For(); + newSubscription.Id = "sub_new_123"; + newSubscription.Status = "active"; + newSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingExpiredSubscription); + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).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()); + 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(); + 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()); + } + + [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(_ => throw new Stripe.StripeException("Subscription not found")); + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var newSubscription = Substitute.For(); + newSubscription.Id = "sub_new_123"; + newSubscription.Status = "active"; + newSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).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()); + 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(); + existingCanceledSubscription.Id = "sub_canceled_123"; + existingCanceledSubscription.Status = "canceled"; // Terminal status + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var newSubscription = Substitute.For(); + newSubscription.Id = "sub_new_123"; + newSubscription.Status = "active"; + newSubscription.Items = new StripeList + { + 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()).Returns(true); // Has old payment method + _updatePaymentMethodCommand.Run(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(mockMaskedPaymentMethod); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).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()); + await _userService.Received(1).SaveUserAsync(user); + } + } diff --git a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs index e0a11741b3..643941da33 100644 --- a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs @@ -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(), Arg.Any()); + } + + [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(), Arg.Any()); + } + [Fact] public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension() {