1
0
mirror of https://github.com/bitwarden/server synced 2026-02-25 17:03:22 +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

@@ -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 =