1
0
mirror of https://github.com/bitwarden/server synced 2026-01-04 17:43:53 +00:00

[PM-28662] Fix Individual Premium automatically disabled due to duplicate subscription leftover from failed payment (#6663)

* Fix the Bug

* Address the hardcode issue

* Fix the tailing test

* resolve the lint issue
This commit is contained in:
cyprain-okeke
2025-12-12 20:19:09 +01:00
committed by GitHub
parent 84b138f431
commit 5ac8536855
2 changed files with 223 additions and 4 deletions

View File

@@ -109,8 +109,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
break;
}
if (subscription.Status is StripeSubscriptionStatus.Unpaid &&
subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
if (await IsPremiumSubscriptionAsync(subscription))
{
await CancelSubscription(subscription.Id);
await VoidOpenInvoices(subscription.Id);
@@ -118,6 +117,20 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
break;
}
case StripeSubscriptionStatus.Incomplete when userId.HasValue:
{
// Handle Incomplete subscriptions for Premium users that have open invoices from failed payments
// This prevents duplicate subscriptions when users retry the subscription flow
if (await IsPremiumSubscriptionAsync(subscription) &&
subscription.LatestInvoice is { Status: StripeInvoiceStatus.Open })
{
await CancelSubscription(subscription.Id);
await VoidOpenInvoices(subscription.Id);
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
}
break;
}
case StripeSubscriptionStatus.Active when organizationId.HasValue:
@@ -190,6 +203,13 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
}
}
private async Task<bool> IsPremiumSubscriptionAsync(Subscription subscription)
{
var premiumPlans = await _pricingClient.ListPremiumPlans();
var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet();
return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id));
}
/// <summary>
/// Checks if the provider subscription status has changed from a non-active to an active status type
/// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false.

View File

@@ -20,6 +20,8 @@ using Quartz;
using Stripe;
using Xunit;
using Event = Stripe.Event;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
namespace Bit.Billing.Test.Services;
@@ -400,6 +402,75 @@ public class SubscriptionUpdatedHandlerTests
var parsedEvent = new Event { Data = new EventData() };
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" }
};
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { premiumPlan });
_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));
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>())
.Returns(new StripeList<Invoice> { Data = new List<Invoice>() });
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userService.Received(1)
.DisablePremiumAsync(userId, currentPeriodEnd);
await _stripeFacade.Received(1)
.CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
await _stripeFacade.Received(1)
.ListInvoices(Arg.Is<InvoiceListOptions>(o =>
o.Status == StripeInvoiceStatus.Open && o.Subscription == subscriptionId));
}
[Fact]
public async Task HandleAsync_IncompleteExpiredUserSubscription_DisablesPremiumAndCancelsSubscription()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.IncompleteExpired,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId }
}
]
}
};
var parsedEvent = new Event { Data = new EventData() };
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" }
};
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { premiumPlan });
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
@@ -565,7 +636,7 @@ public class SubscriptionUpdatedHandlerTests
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
Plan = new Stripe.Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
@@ -599,7 +670,7 @@ public class SubscriptionUpdatedHandlerTests
{
Data =
[
new SubscriptionItem { Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } }
new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-enterprise-seat-annually" } }
]
}
})
@@ -933,6 +1004,134 @@ public class SubscriptionUpdatedHandlerTests
return (providerId, newSubscription, provider, parsedEvent);
}
[Fact]
public async Task HandleAsync_IncompleteUserSubscriptionWithOpenInvoice_CancelsSubscriptionAndDisablesPremium()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var openInvoice = new Invoice
{
Id = "inv_123",
Status = StripeInvoiceStatus.Open
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Incomplete,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
LatestInvoice = openInvoice,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId }
}
]
}
};
var parsedEvent = new Event { Data = new EventData() };
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" }
};
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { premiumPlan });
_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));
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>())
.Returns(new StripeList<Invoice> { Data = new List<Invoice> { openInvoice } });
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userService.Received(1)
.DisablePremiumAsync(userId, currentPeriodEnd);
await _stripeFacade.Received(1)
.CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
await _stripeFacade.Received(1)
.ListInvoices(Arg.Is<InvoiceListOptions>(o =>
o.Status == StripeInvoiceStatus.Open && o.Subscription == subscriptionId));
await _stripeFacade.Received(1)
.VoidInvoice(openInvoice.Id);
}
[Fact]
public async Task HandleAsync_IncompleteUserSubscriptionWithoutOpenInvoice_DoesNotCancelSubscription()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var paidInvoice = new Invoice
{
Id = "inv_123",
Status = StripeInvoiceStatus.Paid
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Incomplete,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
LatestInvoice = paidInvoice,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId }
}
]
}
};
var parsedEvent = new Event { Data = new EventData() };
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" }
};
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { premiumPlan });
_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);
// Assert
await _userService.DidNotReceive()
.DisablePremiumAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _stripeFacade.DidNotReceive()
.CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
await _stripeFacade.DidNotReceive()
.ListInvoices(Arg.Any<InvoiceListOptions>());
}
public static IEnumerable<object[]> GetNonActiveSubscriptions()
{
return new List<object[]>