mirror of
https://github.com/bitwarden/server.git
synced 2026-01-31 14:13:18 +08: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:
@@ -109,8 +109,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscription.Status is StripeSubscriptionStatus.Unpaid &&
|
if (await IsPremiumSubscriptionAsync(subscription))
|
||||||
subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
|
|
||||||
{
|
{
|
||||||
await CancelSubscription(subscription.Id);
|
await CancelSubscription(subscription.Id);
|
||||||
await VoidOpenInvoices(subscription.Id);
|
await VoidOpenInvoices(subscription.Id);
|
||||||
@@ -118,6 +117,20 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
|
|
||||||
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case StripeSubscriptionStatus.Active when organizationId.HasValue:
|
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>
|
/// <summary>
|
||||||
/// Checks if the provider subscription status has changed from a non-active to an active status type
|
/// 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.
|
/// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ using Quartz;
|
|||||||
using Stripe;
|
using Stripe;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Event = Stripe.Event;
|
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;
|
namespace Bit.Billing.Test.Services;
|
||||||
|
|
||||||
@@ -400,6 +402,75 @@ public class SubscriptionUpdatedHandlerTests
|
|||||||
|
|
||||||
var parsedEvent = new Event { Data = new EventData() };
|
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>>())
|
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||||
.Returns(subscription);
|
.Returns(subscription);
|
||||||
|
|
||||||
@@ -565,7 +636,7 @@ public class SubscriptionUpdatedHandlerTests
|
|||||||
new SubscriptionItem
|
new SubscriptionItem
|
||||||
{
|
{
|
||||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
|
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 =
|
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);
|
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()
|
public static IEnumerable<object[]> GetNonActiveSubscriptions()
|
||||||
{
|
{
|
||||||
return new List<object[]>
|
return new List<object[]>
|
||||||
|
|||||||
Reference in New Issue
Block a user